@raybase/core 0.0.2 → 0.3.4
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 +21 -0
- package/dist/.admin-build/AdminApp.css +2 -0
- package/dist/.admin-build/AdminApp.js +77 -0
- package/dist/.admin-docs-build/AdminDocsApp.css +2 -0
- package/dist/.admin-docs-build/AdminDocsApp.js +45 -0
- package/dist/actions.d.ts +347 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +661 -0
- package/dist/actions.js.map +1 -0
- package/dist/admin-assets/admin.7738f50720a8ed7b.css +2 -0
- package/dist/admin-assets/admin.8678cb61db34dc81.js +77 -0
- package/dist/admin-boot.d.ts +94 -0
- package/dist/admin-boot.d.ts.map +1 -0
- package/dist/admin-boot.js +139 -0
- package/dist/admin-boot.js.map +1 -0
- package/dist/admin-docs-content.d.ts +95 -0
- package/dist/admin-docs-content.d.ts.map +1 -0
- package/dist/admin-docs-content.js +463 -0
- package/dist/admin-docs-content.js.map +1 -0
- package/dist/admin-docs-ui.d.ts +11 -0
- package/dist/admin-docs-ui.d.ts.map +1 -0
- package/dist/admin-docs-ui.js +23 -0
- package/dist/admin-docs-ui.js.map +1 -0
- package/dist/admin-kit.d.ts +29 -0
- package/dist/admin-kit.js +1557 -0
- package/dist/admin-role.d.ts +8 -0
- package/dist/admin-role.d.ts.map +1 -0
- package/dist/admin-role.js +22 -0
- package/dist/admin-role.js.map +1 -0
- package/dist/admin-ui.d.ts +3 -0
- package/dist/admin-ui.d.ts.map +1 -0
- package/dist/admin-ui.js +16 -0
- package/dist/admin-ui.js.map +1 -0
- package/dist/agent-ai-chat.d.ts +127 -0
- package/dist/agent-ai-chat.d.ts.map +1 -0
- package/dist/agent-ai-chat.js +5975 -0
- package/dist/agent-ai-chat.js.map +1 -0
- package/dist/agent-chapter-archive.d.ts +214 -0
- package/dist/agent-chapter-archive.d.ts.map +1 -0
- package/dist/agent-chapter-archive.js +696 -0
- package/dist/agent-chapter-archive.js.map +1 -0
- package/dist/agent-chapter-search.d.ts +116 -0
- package/dist/agent-chapter-search.d.ts.map +1 -0
- package/dist/agent-chapter-search.js +605 -0
- package/dist/agent-chapter-search.js.map +1 -0
- package/dist/agent-chapter-tools.d.ts +45 -0
- package/dist/agent-chapter-tools.d.ts.map +1 -0
- package/dist/agent-chapter-tools.js +344 -0
- package/dist/agent-chapter-tools.js.map +1 -0
- package/dist/agent-chapters.d.ts +228 -0
- package/dist/agent-chapters.d.ts.map +1 -0
- package/dist/agent-chapters.js +737 -0
- package/dist/agent-chapters.js.map +1 -0
- package/dist/agent-data-parts.d.ts +167 -0
- package/dist/agent-data-parts.d.ts.map +1 -0
- package/dist/agent-data-parts.js +68 -0
- package/dist/agent-data-parts.js.map +1 -0
- package/dist/agent-errors.d.ts +41 -0
- package/dist/agent-errors.d.ts.map +1 -0
- package/dist/agent-errors.js +191 -0
- package/dist/agent-errors.js.map +1 -0
- package/dist/agent-follow-up-store.d.ts +273 -0
- package/dist/agent-follow-up-store.d.ts.map +1 -0
- package/dist/agent-follow-up-store.js +703 -0
- package/dist/agent-follow-up-store.js.map +1 -0
- package/dist/agent-follow-ups.d.ts +314 -0
- package/dist/agent-follow-ups.d.ts.map +1 -0
- package/dist/agent-follow-ups.js +840 -0
- package/dist/agent-follow-ups.js.map +1 -0
- package/dist/agent-memory.d.ts +323 -0
- package/dist/agent-memory.d.ts.map +1 -0
- package/dist/agent-memory.js +1270 -0
- package/dist/agent-memory.js.map +1 -0
- package/dist/agent-message-parts.d.ts +58 -0
- package/dist/agent-message-parts.d.ts.map +1 -0
- package/dist/agent-message-parts.js +163 -0
- package/dist/agent-message-parts.js.map +1 -0
- package/dist/agent-remote-mcp-credentials.d.ts +154 -0
- package/dist/agent-remote-mcp-credentials.d.ts.map +1 -0
- package/dist/agent-remote-mcp-credentials.js +287 -0
- package/dist/agent-remote-mcp-credentials.js.map +1 -0
- package/dist/agent-remote-mcp-crypto.d.ts +32 -0
- package/dist/agent-remote-mcp-crypto.d.ts.map +1 -0
- package/dist/agent-remote-mcp-crypto.js +101 -0
- package/dist/agent-remote-mcp-crypto.js.map +1 -0
- package/dist/agent-remote-mcp-install.d.ts +299 -0
- package/dist/agent-remote-mcp-install.d.ts.map +1 -0
- package/dist/agent-remote-mcp-install.js +785 -0
- package/dist/agent-remote-mcp-install.js.map +1 -0
- package/dist/agent-remote-mcp-oauth.d.ts +73 -0
- package/dist/agent-remote-mcp-oauth.d.ts.map +1 -0
- package/dist/agent-remote-mcp-oauth.js +344 -0
- package/dist/agent-remote-mcp-oauth.js.map +1 -0
- package/dist/agent-remote-mcp-transport.d.ts +46 -0
- package/dist/agent-remote-mcp-transport.d.ts.map +1 -0
- package/dist/agent-remote-mcp-transport.js +225 -0
- package/dist/agent-remote-mcp-transport.js.map +1 -0
- package/dist/agent-remote-mcp.d.ts +147 -0
- package/dist/agent-remote-mcp.d.ts.map +1 -0
- package/dist/agent-remote-mcp.js +681 -0
- package/dist/agent-remote-mcp.js.map +1 -0
- package/dist/agent-room-redaction.d.ts +66 -0
- package/dist/agent-room-redaction.d.ts.map +1 -0
- package/dist/agent-room-redaction.js +264 -0
- package/dist/agent-room-redaction.js.map +1 -0
- package/dist/agent-rooms.d.ts +209 -0
- package/dist/agent-rooms.d.ts.map +1 -0
- package/dist/agent-rooms.js +244 -0
- package/dist/agent-rooms.js.map +1 -0
- package/dist/agent-runtime.d.ts +541 -0
- package/dist/agent-runtime.d.ts.map +1 -0
- package/dist/agent-runtime.js +4260 -0
- package/dist/agent-runtime.js.map +1 -0
- package/dist/agent-skill-archive.d.ts +20 -0
- package/dist/agent-skill-archive.d.ts.map +1 -0
- package/dist/agent-skill-archive.js +316 -0
- package/dist/agent-skill-archive.js.map +1 -0
- package/dist/agent-skill-discovery.d.ts +96 -0
- package/dist/agent-skill-discovery.d.ts.map +1 -0
- package/dist/agent-skill-discovery.js +464 -0
- package/dist/agent-skill-discovery.js.map +1 -0
- package/dist/agent-skill-format.d.ts +57 -0
- package/dist/agent-skill-format.d.ts.map +1 -0
- package/dist/agent-skill-format.js +238 -0
- package/dist/agent-skill-format.js.map +1 -0
- package/dist/agent-skill-import.d.ts +64 -0
- package/dist/agent-skill-import.d.ts.map +1 -0
- package/dist/agent-skill-import.js +295 -0
- package/dist/agent-skill-import.js.map +1 -0
- package/dist/agent-skill-publish.d.ts +51 -0
- package/dist/agent-skill-publish.d.ts.map +1 -0
- package/dist/agent-skill-publish.js +136 -0
- package/dist/agent-skill-publish.js.map +1 -0
- package/dist/agent-skill-store.d.ts +252 -0
- package/dist/agent-skill-store.d.ts.map +1 -0
- package/dist/agent-skill-store.js +514 -0
- package/dist/agent-skill-store.js.map +1 -0
- package/dist/agent-skills.d.ts +85 -0
- package/dist/agent-skills.d.ts.map +1 -0
- package/dist/agent-skills.js +291 -0
- package/dist/agent-skills.js.map +1 -0
- package/dist/agent-transcript-pager.d.ts +77 -0
- package/dist/agent-transcript-pager.d.ts.map +1 -0
- package/dist/agent-transcript-pager.js +210 -0
- package/dist/agent-transcript-pager.js.map +1 -0
- package/dist/agent-user-data-parts.d.ts +20 -0
- package/dist/agent-user-data-parts.d.ts.map +1 -0
- package/dist/agent-user-data-parts.js +161 -0
- package/dist/agent-user-data-parts.js.map +1 -0
- package/dist/agent-user-transcript.d.ts +46 -0
- package/dist/agent-user-transcript.d.ts.map +1 -0
- package/dist/agent-user-transcript.js +106 -0
- package/dist/agent-user-transcript.js.map +1 -0
- package/dist/agent-voice-notes.d.ts +15 -0
- package/dist/agent-voice-notes.d.ts.map +1 -0
- package/dist/agent-voice-notes.js +17 -0
- package/dist/agent-voice-notes.js.map +1 -0
- package/dist/agent-wakes.d.ts +213 -0
- package/dist/agent-wakes.d.ts.map +1 -0
- package/dist/agent-wakes.js +534 -0
- package/dist/agent-wakes.js.map +1 -0
- package/dist/agent.d.ts +1780 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +3123 -0
- package/dist/agent.js.map +1 -0
- package/dist/ai.d.ts +583 -0
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +1858 -0
- package/dist/ai.js.map +1 -0
- package/dist/app-manifest.d.ts +255 -0
- package/dist/app-manifest.d.ts.map +1 -0
- package/dist/app-manifest.js +372 -0
- package/dist/app-manifest.js.map +1 -0
- package/dist/app-users.d.ts +30 -0
- package/dist/app-users.d.ts.map +1 -0
- package/dist/app-users.js +91 -0
- package/dist/app-users.js.map +1 -0
- package/dist/audit-archive.d.ts +85 -0
- package/dist/audit-archive.d.ts.map +1 -0
- package/dist/audit-archive.js +178 -0
- package/dist/audit-archive.js.map +1 -0
- package/dist/audit.d.ts +54 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +95 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth-logger.d.ts +28 -0
- package/dist/auth-logger.d.ts.map +1 -0
- package/dist/auth-logger.js +53 -0
- package/dist/auth-logger.js.map +1 -0
- package/dist/auth-password-secret.d.ts +59 -0
- package/dist/auth-password-secret.d.ts.map +1 -0
- package/dist/auth-password-secret.js +409 -0
- package/dist/auth-password-secret.js.map +1 -0
- package/dist/auth-schema.d.ts +120 -0
- package/dist/auth-schema.d.ts.map +1 -0
- package/dist/auth-schema.js +285 -0
- package/dist/auth-schema.js.map +1 -0
- package/dist/auth.d.ts +321 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +1562 -0
- package/dist/auth.js.map +1 -0
- package/dist/backup-archive.d.ts +257 -0
- package/dist/backup-archive.d.ts.map +1 -0
- package/dist/backup-archive.js +2068 -0
- package/dist/backup-archive.js.map +1 -0
- package/dist/backup-crypto.d.ts +313 -0
- package/dist/backup-crypto.d.ts.map +1 -0
- package/dist/backup-crypto.js +693 -0
- package/dist/backup-crypto.js.map +1 -0
- package/dist/backup-format.d.ts +200 -0
- package/dist/backup-format.d.ts.map +1 -0
- package/dist/backup-format.js +1247 -0
- package/dist/backup-format.js.map +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -0
- package/dist/clock.d.ts +35 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +83 -0
- package/dist/clock.js.map +1 -0
- package/dist/data-client.d.ts +187 -0
- package/dist/data-client.d.ts.map +1 -0
- package/dist/data-client.js +590 -0
- package/dist/data-client.js.map +1 -0
- package/dist/data-secrets.d.ts +164 -0
- package/dist/data-secrets.d.ts.map +1 -0
- package/dist/data-secrets.js +383 -0
- package/dist/data-secrets.js.map +1 -0
- package/dist/db.d.ts +453 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +324 -0
- package/dist/db.js.map +1 -0
- package/dist/dev-activity.d.ts +48 -0
- package/dist/dev-activity.d.ts.map +1 -0
- package/dist/dev-activity.js +50 -0
- package/dist/dev-activity.js.map +1 -0
- package/dist/do.d.ts +429 -0
- package/dist/do.d.ts.map +1 -0
- package/dist/do.js +4526 -0
- package/dist/do.js.map +1 -0
- package/dist/email.d.ts +137 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +527 -0
- package/dist/email.js.map +1 -0
- package/dist/generated/admin-assets.d.ts +16 -0
- package/dist/generated/admin-assets.d.ts.map +1 -0
- package/dist/generated/admin-assets.js +16 -0
- package/dist/generated/admin-assets.js.map +1 -0
- package/dist/generated/admin-docs-assets.d.ts +3 -0
- package/dist/generated/admin-docs-assets.d.ts.map +1 -0
- package/dist/generated/admin-docs-assets.js +3 -0
- package/dist/generated/admin-docs-assets.js.map +1 -0
- package/dist/handoff-consumer.d.ts +79 -0
- package/dist/handoff-consumer.d.ts.map +1 -0
- package/dist/handoff-consumer.js +300 -0
- package/dist/handoff-consumer.js.map +1 -0
- package/dist/handoff.d.ts +70 -0
- package/dist/handoff.d.ts.map +1 -0
- package/dist/handoff.js +228 -0
- package/dist/handoff.js.map +1 -0
- package/dist/host-auth.d.ts +47 -0
- package/dist/host-auth.d.ts.map +1 -0
- package/dist/host-auth.js +59 -0
- package/dist/host-auth.js.map +1 -0
- package/dist/host-user.d.ts +15 -0
- package/dist/host-user.d.ts.map +1 -0
- package/dist/host-user.js +23 -0
- package/dist/host-user.js.map +1 -0
- package/dist/html.d.ts +35 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +634 -0
- package/dist/html.js.map +1 -0
- package/dist/http-error.d.ts +13 -0
- package/dist/http-error.d.ts.map +1 -0
- package/dist/http-error.js +15 -0
- package/dist/http-error.js.map +1 -0
- package/dist/imports.d.ts +219 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +571 -0
- package/dist/imports.js.map +1 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/inference-tape.d.ts +64 -0
- package/dist/inference-tape.d.ts.map +1 -0
- package/dist/inference-tape.js +278 -0
- package/dist/inference-tape.js.map +1 -0
- package/dist/internal.d.ts +15 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +48 -0
- package/dist/internal.js.map +1 -0
- package/dist/jobs.d.ts +796 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +538 -0
- package/dist/jobs.js.map +1 -0
- package/dist/json-schema.d.ts +91 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +884 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/knowledge.d.ts +33 -0
- package/dist/knowledge.d.ts.map +1 -0
- package/dist/knowledge.js +348 -0
- package/dist/knowledge.js.map +1 -0
- package/dist/local-dev-chrome.d.ts +29 -0
- package/dist/local-dev-chrome.d.ts.map +1 -0
- package/dist/local-dev-chrome.js +514 -0
- package/dist/local-dev-chrome.js.map +1 -0
- package/dist/local-dev-personas.d.ts +15 -0
- package/dist/local-dev-personas.d.ts.map +1 -0
- package/dist/local-dev-personas.js +46 -0
- package/dist/local-dev-personas.js.map +1 -0
- package/dist/mcp.d.ts +567 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +2480 -0
- package/dist/mcp.js.map +1 -0
- package/dist/modules.d.ts +145 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +164 -0
- package/dist/modules.js.map +1 -0
- package/dist/notification-client.d.ts +94 -0
- package/dist/notification-client.d.ts.map +1 -0
- package/dist/notification-client.js +127 -0
- package/dist/notification-client.js.map +1 -0
- package/dist/notifications.d.ts +447 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +1625 -0
- package/dist/notifications.js.map +1 -0
- package/dist/operator-data-query.d.ts +117 -0
- package/dist/operator-data-query.d.ts.map +1 -0
- package/dist/operator-data-query.js +431 -0
- package/dist/operator-data-query.js.map +1 -0
- package/dist/operator-mcp.d.ts +63 -0
- package/dist/operator-mcp.d.ts.map +1 -0
- package/dist/operator-mcp.js +2828 -0
- package/dist/operator-mcp.js.map +1 -0
- package/dist/outbound-https.d.ts +24 -0
- package/dist/outbound-https.d.ts.map +1 -0
- package/dist/outbound-https.js +172 -0
- package/dist/outbound-https.js.map +1 -0
- package/dist/pitr.d.ts +60 -0
- package/dist/pitr.d.ts.map +1 -0
- package/dist/pitr.js +94 -0
- package/dist/pitr.js.map +1 -0
- package/dist/platform.d.ts +199 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +196 -0
- package/dist/platform.js.map +1 -0
- package/dist/policies.d.ts +115 -0
- package/dist/policies.d.ts.map +1 -0
- package/dist/policies.js +158 -0
- package/dist/policies.js.map +1 -0
- package/dist/principal.d.ts +143 -0
- package/dist/principal.d.ts.map +1 -0
- package/dist/principal.js +174 -0
- package/dist/principal.js.map +1 -0
- package/dist/realtime.d.ts +280 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +578 -0
- package/dist/realtime.js.map +1 -0
- package/dist/retrieval.d.ts +263 -0
- package/dist/retrieval.d.ts.map +1 -0
- package/dist/retrieval.js +602 -0
- package/dist/retrieval.js.map +1 -0
- package/dist/runtime-error-client.d.ts +10 -0
- package/dist/runtime-error-client.d.ts.map +1 -0
- package/dist/runtime-error-client.js +70 -0
- package/dist/runtime-error-client.js.map +1 -0
- package/dist/runtime-errors.d.ts +180 -0
- package/dist/runtime-errors.d.ts.map +1 -0
- package/dist/runtime-errors.js +572 -0
- package/dist/runtime-errors.js.map +1 -0
- package/dist/schema.d.ts +434 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +232 -0
- package/dist/schema.js.map +1 -0
- package/dist/search.d.ts +216 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +785 -0
- package/dist/search.js.map +1 -0
- package/dist/settings.d.ts +191 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +478 -0
- package/dist/settings.js.map +1 -0
- package/dist/sql.d.ts +7 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +30 -0
- package/dist/sql.js.map +1 -0
- package/dist/storage-client.d.ts +69 -0
- package/dist/storage-client.d.ts.map +1 -0
- package/dist/storage-client.js +469 -0
- package/dist/storage-client.js.map +1 -0
- package/dist/storage.d.ts +21 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +82 -0
- package/dist/storage.js.map +1 -0
- package/dist/subject-domains.d.ts +15 -0
- package/dist/subject-domains.d.ts.map +1 -0
- package/dist/subject-domains.js +38 -0
- package/dist/subject-domains.js.map +1 -0
- package/dist/support.d.ts +132 -0
- package/dist/support.d.ts.map +1 -0
- package/dist/support.js +483 -0
- package/dist/support.js.map +1 -0
- package/dist/system-tables.d.ts +12 -0
- package/dist/system-tables.d.ts.map +1 -0
- package/dist/system-tables.js +225 -0
- package/dist/system-tables.js.map +1 -0
- package/dist/test.d.ts +57 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +454 -0
- package/dist/test.js.map +1 -0
- package/dist/vite.d.ts +82 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +82 -0
- package/dist/vite.js.map +1 -0
- package/dist/webpush-crypto.d.ts +39 -0
- package/dist/webpush-crypto.d.ts.map +1 -0
- package/dist/webpush-crypto.js +213 -0
- package/dist/webpush-crypto.js.map +1 -0
- package/dist/webpush-private.d.ts +11 -0
- package/dist/webpush-private.d.ts.map +1 -0
- package/dist/webpush-private.js +11 -0
- package/dist/webpush-private.js.map +1 -0
- package/dist/webpush.d.ts +86 -0
- package/dist/webpush.d.ts.map +1 -0
- package/dist/webpush.js +375 -0
- package/dist/webpush.js.map +1 -0
- package/dist/worker.d.ts +185 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +13021 -0
- package/dist/worker.js.map +1 -0
- package/package.json +161 -8
- package/README.md +0 -3
|
@@ -0,0 +1,2068 @@
|
|
|
1
|
+
import { sha256Hex } from "./audit-archive.js";
|
|
2
|
+
import { audit, parseStoredAppRoles, raybaseAuthExecutor } from "./auth.js";
|
|
3
|
+
import { assertRaybasePasswordSecretParticipantsReady, createRaybasePasswordWrapParticipants, listRaybasePasswordSecretReadiness, raybasePasswordSecretPurpose } from "./auth-password-secret.js";
|
|
4
|
+
import { createDeploymentBackupKeyProvider, backupProviderLimits, generateBackupDek, RaybaseBackupError, unwrapBackupDek, wrapBackupDek } from "./backup-crypto.js";
|
|
5
|
+
import { BackupLogicalDecoder, backupArchiveMagic, backupBinaryContentType, backupFrameHeaderBytes, backupFrameTagBytes, backupFormatLimits, canonicalBackupJson, createBackupArchiveDecryptor, createBackupArchiveEncryptor, createBackupSha256Digest, decodeBackupArchiveHeader, encodeBackupArchiveHeader, encodeBackupLogicalRecord } from "./backup-format.js";
|
|
6
|
+
import { backupDatabaseProtocolLimits, dataStub, retryConsistentBackupSnapshot } from "./db.js";
|
|
7
|
+
import { pitrUnavailableReason, recoverInterruptedBackupRestore } from "./pitr.js";
|
|
8
|
+
export const backupSqlContentType = "application/sql; charset=utf-8";
|
|
9
|
+
export const backupRootManifestKey = "backups/manifest.json";
|
|
10
|
+
export const coreBackupManifestKey = "backups/core/manifest.json";
|
|
11
|
+
export const coreBackupPrefix = "backups/core/";
|
|
12
|
+
export const backupTemporaryPrefix = "backups/.temporary/";
|
|
13
|
+
export const backupTemporaryRetentionMs = 24 * 60 * 60 * 1000;
|
|
14
|
+
export const backupLegacyUploadMaximumBytes = 16 * 1024 * 1024;
|
|
15
|
+
export const backupMultipartMinimumPartBytes = 5 * 1024 * 1024;
|
|
16
|
+
export const backupMultipartDefaultPartBytes = 8 * 1024 * 1024;
|
|
17
|
+
export const backupMultipartMaximumParts = 10_000;
|
|
18
|
+
export const backupMultipartMaximumPartBytes = 5 * 1024 * 1024 * 1024;
|
|
19
|
+
export const backupMultipartMaximumBufferedPartBytes = 64 * 1024 * 1024;
|
|
20
|
+
export const backupMultipartMaximumObjectBytes = 5 * 1024 * 1024 * 1024 * 1024;
|
|
21
|
+
export const backupArchiveDiscoveryMaximumObjects = 10_000;
|
|
22
|
+
const defaultRetention = 30;
|
|
23
|
+
const backupArchiveDiscoveryMaximumWarnings = 20;
|
|
24
|
+
const backupArchiveDataFrameKind = 1;
|
|
25
|
+
const backupArchiveFinalFrameKind = 2;
|
|
26
|
+
const backupArchiveFinalFrameMaximumBytes = 4096;
|
|
27
|
+
function normalizedFlag(value) {
|
|
28
|
+
const trimmed = value?.trim().toLowerCase();
|
|
29
|
+
return trimmed ? trimmed : null;
|
|
30
|
+
}
|
|
31
|
+
export function backupsEnabled(env) {
|
|
32
|
+
const value = normalizedFlag(env.RAYBASE_BACKUPS_ENABLED);
|
|
33
|
+
if (value && ["false", "0", "no", "off"].includes(value))
|
|
34
|
+
return false;
|
|
35
|
+
if (value)
|
|
36
|
+
return ["true", "1", "yes", "on"].includes(value);
|
|
37
|
+
return Boolean(env.RAYBASE_BACKUPS);
|
|
38
|
+
}
|
|
39
|
+
export function parseBackupRetention(value) {
|
|
40
|
+
if (value === undefined || value.trim() === "")
|
|
41
|
+
return defaultRetention;
|
|
42
|
+
const retention = Number(value);
|
|
43
|
+
if (!Number.isInteger(retention) || retention < 1) {
|
|
44
|
+
throw new Error("RAYBASE_BACKUPS_RETAIN must be a positive integer");
|
|
45
|
+
}
|
|
46
|
+
return retention;
|
|
47
|
+
}
|
|
48
|
+
function backupBytesToHex(value) {
|
|
49
|
+
return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
50
|
+
}
|
|
51
|
+
function retainedDeploymentSecrets(value) {
|
|
52
|
+
return (value ?? "")
|
|
53
|
+
.split(/[\s,]+/)
|
|
54
|
+
.map((secret) => secret.trim())
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
async function backupProviderSet(env, backups) {
|
|
58
|
+
const currentSecret = env.RAYBASE_BACKUPS_KEY?.trim();
|
|
59
|
+
const deploymentProvider = currentSecret
|
|
60
|
+
? await createDeploymentBackupKeyProvider({
|
|
61
|
+
currentSecret,
|
|
62
|
+
retainedSecrets: retainedDeploymentSecrets(env.RAYBASE_BACKUPS_KEY_RETAINED)
|
|
63
|
+
})
|
|
64
|
+
: undefined;
|
|
65
|
+
return {
|
|
66
|
+
...(backups?.keyProvider ? { keyProvider: backups.keyProvider } : {}),
|
|
67
|
+
...(deploymentProvider ? { deploymentProvider } : {})
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function selectedBackupProvider(providers) {
|
|
71
|
+
return providers.keyProvider ?? providers.deploymentProvider;
|
|
72
|
+
}
|
|
73
|
+
function backupReadinessContext(environment) {
|
|
74
|
+
return {
|
|
75
|
+
archiveId: crypto.randomUUID(),
|
|
76
|
+
product: "core",
|
|
77
|
+
target: "core",
|
|
78
|
+
targetId: "core",
|
|
79
|
+
createdAt: new Date().toISOString(),
|
|
80
|
+
purpose: "readiness",
|
|
81
|
+
signal: new AbortController().signal,
|
|
82
|
+
...(environment ? { environment } : {})
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function probeBackupProviderForCreation(env, providers, context) {
|
|
86
|
+
const dek = generateBackupDek();
|
|
87
|
+
const selected = selectedBackupProvider(providers);
|
|
88
|
+
const wrapParticipants = selected
|
|
89
|
+
? await resolvePasswordParticipantsForWrap(env, selected, context)
|
|
90
|
+
: { participants: Object.freeze([]), release() { } };
|
|
91
|
+
try {
|
|
92
|
+
return await wrapBackupDek({ providers, context, dek, participants: wrapParticipants.participants });
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
wrapParticipants.release();
|
|
96
|
+
dek.fill(0);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const participantDirectoryStringPattern = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
100
|
+
function validatedDirectoryStrings(value, label, allowNull = false) {
|
|
101
|
+
if (value === undefined)
|
|
102
|
+
return [];
|
|
103
|
+
if (!Array.isArray(value) || value.length > 32) {
|
|
104
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", `${label} must contain at most 32 values`);
|
|
105
|
+
}
|
|
106
|
+
const values = value.map((item) => {
|
|
107
|
+
if (allowNull && item === null)
|
|
108
|
+
return null;
|
|
109
|
+
if (typeof item !== "string" ||
|
|
110
|
+
!participantDirectoryStringPattern.test(item) ||
|
|
111
|
+
new TextEncoder().encode(item).byteLength > backupProviderLimits.identifierBytes) {
|
|
112
|
+
throw new RaybaseBackupError("BACKUP_PROVIDER_ENVELOPE_INVALID", `${label} contains an invalid value`);
|
|
113
|
+
}
|
|
114
|
+
return item;
|
|
115
|
+
});
|
|
116
|
+
if (new Set(values).size !== values.length) {
|
|
117
|
+
throw new RaybaseBackupError("BACKUP_PROVIDER_ENVELOPE_INVALID", `${label} contains duplicate values`);
|
|
118
|
+
}
|
|
119
|
+
return values;
|
|
120
|
+
}
|
|
121
|
+
function createPasswordParticipantDirectory(env, provider) {
|
|
122
|
+
const lifecycle = provider.passwordSecrets;
|
|
123
|
+
if (!lifecycle) {
|
|
124
|
+
throw new RaybaseBackupError("BACKUP_PROVIDER_UNAVAILABLE", "The backup provider has no password lifecycle");
|
|
125
|
+
}
|
|
126
|
+
const purpose = `${raybasePasswordSecretPurpose}:${provider.id}`;
|
|
127
|
+
const lifecycleVersion = lifecycle.version.trim();
|
|
128
|
+
return Object.freeze({
|
|
129
|
+
async find(request = {}) {
|
|
130
|
+
const limit = request.limit ?? 50;
|
|
131
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > backupProviderLimits.participantDirectoryPageRecords) {
|
|
132
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", `Backup participant directory pages support 1-${backupProviderLimits.participantDirectoryPageRecords} records`);
|
|
133
|
+
}
|
|
134
|
+
const afterUserId = request.afterUserId?.trim();
|
|
135
|
+
if (request.afterUserId !== undefined &&
|
|
136
|
+
(!afterUserId || new TextEncoder().encode(afterUserId).byteLength > 256)) {
|
|
137
|
+
throw new RaybaseBackupError("BACKUP_PROVIDER_ENVELOPE_INVALID", "Backup participant directory cursor is invalid");
|
|
138
|
+
}
|
|
139
|
+
const filter = request.filter ?? {};
|
|
140
|
+
const allRoles = validatedDirectoryStrings(filter.appRoles?.all, "Backup participant all-role filter");
|
|
141
|
+
const anyRoles = validatedDirectoryStrings(filter.appRoles?.any, "Backup participant any-role filter");
|
|
142
|
+
const noRoles = validatedDirectoryStrings(filter.appRoles?.none, "Backup participant excluded-role filter");
|
|
143
|
+
const operatorRoles = validatedDirectoryStrings(filter.operatorRoles, "Backup participant operator-role filter", true);
|
|
144
|
+
const lifecycleEligibility = filter.lifecycleEligibility === undefined
|
|
145
|
+
? []
|
|
146
|
+
: [...filter.lifecycleEligibility];
|
|
147
|
+
if (lifecycleEligibility.length > 2 ||
|
|
148
|
+
new Set(lifecycleEligibility).size !== lifecycleEligibility.length ||
|
|
149
|
+
lifecycleEligibility.some((value) => value !== "ready" && value !== "not-ready")) {
|
|
150
|
+
throw new RaybaseBackupError("BACKUP_PROVIDER_ENVELOPE_INVALID", "Backup participant lifecycle filter is invalid");
|
|
151
|
+
}
|
|
152
|
+
const where = [`COALESCE("banned", 0) = 0`];
|
|
153
|
+
const parameters = [provider.id, lifecycleVersion, purpose];
|
|
154
|
+
if (afterUserId) {
|
|
155
|
+
where.push(`"userId" > ?`);
|
|
156
|
+
parameters.push(afterUserId);
|
|
157
|
+
}
|
|
158
|
+
for (const role of allRoles) {
|
|
159
|
+
if (role === "user")
|
|
160
|
+
continue;
|
|
161
|
+
where.push(`EXISTS (SELECT 1 FROM json_each(COALESCE("appRoles", '["user"]')) WHERE value = ?)`);
|
|
162
|
+
parameters.push(role);
|
|
163
|
+
}
|
|
164
|
+
if (anyRoles.length && !anyRoles.includes("user")) {
|
|
165
|
+
where.push(`EXISTS (SELECT 1 FROM json_each(COALESCE("appRoles", '["user"]')) WHERE value IN (${anyRoles.map(() => "?").join(", ")}))`);
|
|
166
|
+
parameters.push(...anyRoles);
|
|
167
|
+
}
|
|
168
|
+
for (const role of noRoles) {
|
|
169
|
+
if (role === "user") {
|
|
170
|
+
where.push("0 = 1");
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
where.push(`NOT EXISTS (SELECT 1 FROM json_each(COALESCE("appRoles", '["user"]')) WHERE value = ?)`);
|
|
174
|
+
parameters.push(role);
|
|
175
|
+
}
|
|
176
|
+
if (operatorRoles.length) {
|
|
177
|
+
const nonNull = operatorRoles.filter((value) => value !== null);
|
|
178
|
+
const choices = [];
|
|
179
|
+
if (nonNull.length) {
|
|
180
|
+
choices.push(`"operatorRole" IN (${nonNull.map(() => "?").join(", ")})`);
|
|
181
|
+
parameters.push(...nonNull);
|
|
182
|
+
}
|
|
183
|
+
if (operatorRoles.includes(null))
|
|
184
|
+
choices.push(`"operatorRole" IS NULL`);
|
|
185
|
+
where.push(`(${choices.join(" OR ")})`);
|
|
186
|
+
}
|
|
187
|
+
if (lifecycleEligibility.length) {
|
|
188
|
+
where.push(`"lifecycleEligibility" IN (${lifecycleEligibility.map(() => "?").join(", ")})`);
|
|
189
|
+
parameters.push(...lifecycleEligibility);
|
|
190
|
+
}
|
|
191
|
+
parameters.push(limit);
|
|
192
|
+
const rows = await raybaseAuthExecutor(env).all(`WITH "participantDirectory" AS (
|
|
193
|
+
SELECT u."id" AS "userId", u."appRoles",
|
|
194
|
+
CASE WHEN u."operatorRole" IS NULL OR u."operatorRole" IN ('', 'none')
|
|
195
|
+
THEN NULL ELSE u."operatorRole" END AS "operatorRole",
|
|
196
|
+
u."banned",
|
|
197
|
+
CASE WHEN s."providerId" = ? AND s."providerVersion" = ?
|
|
198
|
+
AND s."lastFailureCode" IS NULL
|
|
199
|
+
AND s."readiness" IN ('ready', 'reset-destructive')
|
|
200
|
+
THEN 'ready' ELSE 'not-ready' END AS "lifecycleEligibility"
|
|
201
|
+
FROM "_auth_users" u
|
|
202
|
+
LEFT JOIN "_auth_password_secret_state" s
|
|
203
|
+
ON s."userId" = u."id" AND s."purpose" = ?
|
|
204
|
+
-- Host users (RaybaseHostAuth) have no password and hold no roles.
|
|
205
|
+
WHERE u."id" NOT LIKE 'hostuser\\_%' ESCAPE '\\'
|
|
206
|
+
)
|
|
207
|
+
SELECT "userId", "appRoles", "operatorRole", "lifecycleEligibility"
|
|
208
|
+
FROM "participantDirectory"
|
|
209
|
+
WHERE ${where.join(" AND ")}
|
|
210
|
+
ORDER BY "userId" ASC
|
|
211
|
+
LIMIT ?`, parameters);
|
|
212
|
+
return Object.freeze(rows.map((row) => Object.freeze({
|
|
213
|
+
userId: row.userId,
|
|
214
|
+
appRoles: Object.freeze(parseStoredAppRoles(row.appRoles)),
|
|
215
|
+
operatorRole: row.operatorRole,
|
|
216
|
+
lifecycleEligibility: row.lifecycleEligibility
|
|
217
|
+
})));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
async function requiredPasswordParticipantUserIds(env, provider, context) {
|
|
222
|
+
const lifecycle = provider.passwordSecrets;
|
|
223
|
+
if (!lifecycle)
|
|
224
|
+
return null;
|
|
225
|
+
if (lifecycle.resolveRequiredParticipants && lifecycle.requiredParticipantUserIds) {
|
|
226
|
+
throw new RaybaseBackupError("BACKUP_PROVIDER_UNAVAILABLE", "The password-coupled backup provider declares conflicting participant resolvers");
|
|
227
|
+
}
|
|
228
|
+
if (!lifecycle.resolveRequiredParticipants && !lifecycle.requiredParticipantUserIds) {
|
|
229
|
+
throw new RaybaseBackupError("BACKUP_THRESHOLD_UNMET", "The password-coupled backup provider does not declare its required participants");
|
|
230
|
+
}
|
|
231
|
+
let raw;
|
|
232
|
+
try {
|
|
233
|
+
raw = lifecycle.resolveRequiredParticipants
|
|
234
|
+
? await lifecycle.resolveRequiredParticipants(Object.freeze({
|
|
235
|
+
context,
|
|
236
|
+
directory: createPasswordParticipantDirectory(env, provider)
|
|
237
|
+
}))
|
|
238
|
+
: await lifecycle.requiredParticipantUserIds(context);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
throw new RaybaseBackupError("BACKUP_PROVIDER_UNAVAILABLE", "The backup key provider could not resolve required password-secret participants");
|
|
242
|
+
}
|
|
243
|
+
if (!Array.isArray(raw) || raw.length < 1 || raw.length > backupProviderLimits.wrappedKeyRecords) {
|
|
244
|
+
throw new RaybaseBackupError("BACKUP_THRESHOLD_UNMET", `Password-coupled backup providers must declare 1-${backupProviderLimits.wrappedKeyRecords} participants`);
|
|
245
|
+
}
|
|
246
|
+
const ids = raw.map((value) => typeof value === "string" ? value.trim() : "");
|
|
247
|
+
if (ids.some((value) => !value || new TextEncoder().encode(value).byteLength > 256) || new Set(ids).size !== ids.length) {
|
|
248
|
+
throw new RaybaseBackupError("BACKUP_THRESHOLD_UNMET", "The password-coupled backup provider returned invalid or duplicate participant ids");
|
|
249
|
+
}
|
|
250
|
+
return ids;
|
|
251
|
+
}
|
|
252
|
+
async function resolvePasswordParticipantsForWrap(env, provider, context) {
|
|
253
|
+
if (!provider.passwordSecrets)
|
|
254
|
+
return { participants: Object.freeze([]), release() { } };
|
|
255
|
+
const ids = await requiredPasswordParticipantUserIds(env, provider, context);
|
|
256
|
+
if (!ids)
|
|
257
|
+
return { participants: Object.freeze([]), release() { } };
|
|
258
|
+
if (provider.mode !== "wrapping") {
|
|
259
|
+
await assertRaybasePasswordSecretParticipantsReady(raybaseAuthExecutor(env), { providerId: provider.id, lifecycle: provider.passwordSecrets }, ids);
|
|
260
|
+
return { participants: Object.freeze([]), release() { } };
|
|
261
|
+
}
|
|
262
|
+
return createRaybasePasswordWrapParticipants(raybaseAuthExecutor(env), { providerId: provider.id, lifecycle: provider.passwordSecrets }, ids);
|
|
263
|
+
}
|
|
264
|
+
async function passwordParticipantReadiness(env, provider, context, assertReady) {
|
|
265
|
+
if (!provider.passwordSecrets)
|
|
266
|
+
return undefined;
|
|
267
|
+
let ids;
|
|
268
|
+
try {
|
|
269
|
+
ids = (await requiredPasswordParticipantUserIds(env, provider, context)) ?? [];
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (assertReady)
|
|
273
|
+
throw error;
|
|
274
|
+
return {
|
|
275
|
+
declared: false,
|
|
276
|
+
readyForNewBackups: false,
|
|
277
|
+
required: null,
|
|
278
|
+
ready: null,
|
|
279
|
+
statuses: {},
|
|
280
|
+
reason: error instanceof RaybaseBackupError
|
|
281
|
+
? error.message
|
|
282
|
+
: "Password-secret participant readiness could not be determined."
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const registration = { providerId: provider.id, lifecycle: provider.passwordSecrets };
|
|
286
|
+
let readiness;
|
|
287
|
+
try {
|
|
288
|
+
readiness = assertReady
|
|
289
|
+
? await assertRaybasePasswordSecretParticipantsReady(raybaseAuthExecutor(env), registration, ids)
|
|
290
|
+
: await listRaybasePasswordSecretReadiness(raybaseAuthExecutor(env), registration, ids);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (assertReady) {
|
|
294
|
+
if (error instanceof RaybaseBackupError)
|
|
295
|
+
throw error;
|
|
296
|
+
throw new RaybaseBackupError("BACKUP_PROVIDER_UNAVAILABLE", "Password-secret participant readiness could not be determined");
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
declared: true,
|
|
300
|
+
readyForNewBackups: false,
|
|
301
|
+
required: ids.length,
|
|
302
|
+
ready: null,
|
|
303
|
+
statuses: {},
|
|
304
|
+
reason: "Password-secret participant readiness could not be determined."
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const byUser = new Map(readiness.map((item) => [item.userId, item]));
|
|
308
|
+
const statuses = {};
|
|
309
|
+
let ready = 0;
|
|
310
|
+
for (const id of ids) {
|
|
311
|
+
const item = byUser.get(id);
|
|
312
|
+
const status = item?.status ?? "missing";
|
|
313
|
+
statuses[status] = (statuses[status] ?? 0) + 1;
|
|
314
|
+
if (item?.readyForNewBackups)
|
|
315
|
+
ready += 1;
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
declared: true,
|
|
319
|
+
readyForNewBackups: ready === ids.length,
|
|
320
|
+
required: ids.length,
|
|
321
|
+
ready,
|
|
322
|
+
statuses,
|
|
323
|
+
...(ready === ids.length
|
|
324
|
+
? {}
|
|
325
|
+
: { reason: `Password-secret participants are not ready (${ids.length - ready} unavailable).` })
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
export async function readCoreBackupRestoreRuntimeStatus(env) {
|
|
329
|
+
const unavailable = pitrUnavailableReason(env);
|
|
330
|
+
const maintenance = env.RAYBASE_DATA ? await dataStub(env).backupRestoreStatus() : null;
|
|
331
|
+
return {
|
|
332
|
+
pitrAvailable: unavailable === null,
|
|
333
|
+
...(unavailable ? { pitrUnavailableReason: unavailable } : {}),
|
|
334
|
+
maintenance
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
export async function prepareCoreBackupRestore(env, options) {
|
|
338
|
+
const runtime = await readCoreBackupRestoreRuntimeStatus(env);
|
|
339
|
+
if (runtime.maintenance?.active) {
|
|
340
|
+
if (!runtime.pitrAvailable) {
|
|
341
|
+
throw new RaybaseBackupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", `Interrupted restore maintenance requires production PITR recovery. ${runtime.pitrUnavailableReason ?? "PITR is unavailable."}`);
|
|
342
|
+
}
|
|
343
|
+
const recovered = await recoverInterruptedBackupRestore({ stub: dataStub(env) });
|
|
344
|
+
if (recovered.recoveryRequested) {
|
|
345
|
+
throw new RaybaseBackupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "Interrupted restore rollback was requested. Retry after the database Durable Object restarts.");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (options.requirePitr && !runtime.pitrAvailable) {
|
|
349
|
+
throw new RaybaseBackupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", `Encrypted restore requires production PITR recovery support. ${runtime.pitrUnavailableReason ?? "PITR is unavailable."}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Consume exact encrypted archive bytes into legal R2 multipart parts while
|
|
354
|
+
* independently hashing the bytes submitted to the upload session. All
|
|
355
|
+
* non-final parts meet R2's 5 MiB floor and uploads are deliberately
|
|
356
|
+
* sequential, bounding retained memory to one configured part plus one input
|
|
357
|
+
* chunk owned by the upstream stream.
|
|
358
|
+
*/
|
|
359
|
+
export async function uploadBackupMultipartStream(upload, stream, options = {}) {
|
|
360
|
+
const partBytes = options.partBytes ?? backupMultipartDefaultPartBytes;
|
|
361
|
+
const maximumParts = options.maximumParts ?? backupMultipartMaximumParts;
|
|
362
|
+
const abortUpload = async () => {
|
|
363
|
+
try {
|
|
364
|
+
await upload.abort();
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// Preserve the primary configuration, stream, or upload failure.
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
try {
|
|
371
|
+
if (!Number.isSafeInteger(partBytes) ||
|
|
372
|
+
partBytes < backupMultipartMinimumPartBytes ||
|
|
373
|
+
partBytes > backupMultipartMaximumPartBytes) {
|
|
374
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", `R2 multipart parts must contain ${backupMultipartMinimumPartBytes}-${backupMultipartMaximumPartBytes} bytes`);
|
|
375
|
+
}
|
|
376
|
+
if (partBytes > backupMultipartMaximumBufferedPartBytes) {
|
|
377
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", `Core bounds in-memory backup parts to ${backupMultipartMaximumBufferedPartBytes} bytes`);
|
|
378
|
+
}
|
|
379
|
+
if (!Number.isInteger(maximumParts) ||
|
|
380
|
+
maximumParts < 1 ||
|
|
381
|
+
maximumParts > backupMultipartMaximumParts) {
|
|
382
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", `Backup multipart uploads support 1-${backupMultipartMaximumParts} parts`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
await abortUpload();
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
let digest;
|
|
390
|
+
let reader;
|
|
391
|
+
try {
|
|
392
|
+
digest = (options.digestFactory ?? createBackupSha256Digest)();
|
|
393
|
+
reader = stream.getReader();
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
await abortUpload();
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
const uploadedParts = [];
|
|
400
|
+
let pending;
|
|
401
|
+
try {
|
|
402
|
+
pending = new Uint8Array(partBytes);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
reader.releaseLock();
|
|
406
|
+
await digest.abort(error);
|
|
407
|
+
await abortUpload();
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
let pendingLength = 0;
|
|
411
|
+
let bytes = 0;
|
|
412
|
+
let completed = false;
|
|
413
|
+
const uploadPart = async (part) => {
|
|
414
|
+
if (uploadedParts.length >= maximumParts) {
|
|
415
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", "Backup exceeds the R2 multipart part-count limit");
|
|
416
|
+
}
|
|
417
|
+
if (!Number.isSafeInteger(bytes + part.byteLength)) {
|
|
418
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", "Backup exceeds the safe byte-count limit");
|
|
419
|
+
}
|
|
420
|
+
if (bytes + part.byteLength > backupMultipartMaximumObjectBytes) {
|
|
421
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", "Backup exceeds the R2 object-size limit");
|
|
422
|
+
}
|
|
423
|
+
await digest.write(part);
|
|
424
|
+
const partNumber = uploadedParts.length + 1;
|
|
425
|
+
const uploaded = await upload.uploadPart(partNumber, part);
|
|
426
|
+
if (!uploaded ||
|
|
427
|
+
uploaded.partNumber !== partNumber ||
|
|
428
|
+
typeof uploaded.etag !== "string" ||
|
|
429
|
+
uploaded.etag.length === 0) {
|
|
430
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "R2 returned invalid multipart upload evidence");
|
|
431
|
+
}
|
|
432
|
+
uploadedParts.push({ partNumber, etag: uploaded.etag });
|
|
433
|
+
bytes += part.byteLength;
|
|
434
|
+
};
|
|
435
|
+
try {
|
|
436
|
+
while (true) {
|
|
437
|
+
const result = await reader.read();
|
|
438
|
+
if (result.done)
|
|
439
|
+
break;
|
|
440
|
+
const chunk = result.value;
|
|
441
|
+
if (!(chunk instanceof Uint8Array)) {
|
|
442
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup upload stream must emit Uint8Array chunks");
|
|
443
|
+
}
|
|
444
|
+
let offset = 0;
|
|
445
|
+
while (offset < chunk.byteLength) {
|
|
446
|
+
const take = Math.min(partBytes - pendingLength, chunk.byteLength - offset);
|
|
447
|
+
pending.set(chunk.subarray(offset, offset + take), pendingLength);
|
|
448
|
+
pendingLength += take;
|
|
449
|
+
offset += take;
|
|
450
|
+
if (pendingLength === partBytes) {
|
|
451
|
+
const part = pending;
|
|
452
|
+
try {
|
|
453
|
+
await uploadPart(part);
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
part.fill(0);
|
|
457
|
+
}
|
|
458
|
+
pending = new Uint8Array(partBytes);
|
|
459
|
+
pendingLength = 0;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (pendingLength > 0) {
|
|
464
|
+
const part = pending.slice(0, pendingLength);
|
|
465
|
+
pending.fill(0);
|
|
466
|
+
try {
|
|
467
|
+
await uploadPart(part);
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
part.fill(0);
|
|
471
|
+
}
|
|
472
|
+
pendingLength = 0;
|
|
473
|
+
}
|
|
474
|
+
if (uploadedParts.length === 0) {
|
|
475
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_TRUNCATED", "Encrypted backup archive emitted no bytes");
|
|
476
|
+
}
|
|
477
|
+
const sha256Bytes = await digest.finish();
|
|
478
|
+
const sha256 = backupBytesToHex(sha256Bytes);
|
|
479
|
+
sha256Bytes.fill(0);
|
|
480
|
+
const object = await upload.complete(uploadedParts);
|
|
481
|
+
completed = true;
|
|
482
|
+
return {
|
|
483
|
+
object,
|
|
484
|
+
uploadedParts,
|
|
485
|
+
partCount: uploadedParts.length,
|
|
486
|
+
bytes,
|
|
487
|
+
sha256
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
pending.fill(0);
|
|
492
|
+
pendingLength = 0;
|
|
493
|
+
try {
|
|
494
|
+
await reader.cancel(error);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
// Preserve the primary upload/encryption failure.
|
|
498
|
+
}
|
|
499
|
+
await digest.abort(error);
|
|
500
|
+
await abortUpload();
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
reader.releaseLock();
|
|
505
|
+
pending.fill(0);
|
|
506
|
+
if (!completed)
|
|
507
|
+
pendingLength = 0;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
export async function uploadBackupArchiveMultipart(bucket, key, stream, options = {}) {
|
|
511
|
+
const upload = await bucket.createMultipartUpload(key, options.r2);
|
|
512
|
+
return uploadBackupMultipartStream(upload, stream, options);
|
|
513
|
+
}
|
|
514
|
+
export function backupTimestamp(date = new Date()) {
|
|
515
|
+
return `${date.toISOString().slice(0, 19).replace(/:/g, "-")}Z`;
|
|
516
|
+
}
|
|
517
|
+
export function backupMetadataComment(metadata) {
|
|
518
|
+
return `-- raybase-backup: ${JSON.stringify(metadata)}`;
|
|
519
|
+
}
|
|
520
|
+
export function prependBackupMetadata(sql, metadata) {
|
|
521
|
+
return `${backupMetadataComment(metadata)}\n${sql}`;
|
|
522
|
+
}
|
|
523
|
+
export function parseBackupMetadata(sql) {
|
|
524
|
+
const firstLine = sql.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
525
|
+
const match = firstLine.match(/^--\s*raybase-backup:\s*(\{.*\})$/);
|
|
526
|
+
if (!match)
|
|
527
|
+
return null;
|
|
528
|
+
try {
|
|
529
|
+
const parsed = JSON.parse(match[1]);
|
|
530
|
+
if (parsed.format !== 1)
|
|
531
|
+
return null;
|
|
532
|
+
if (parsed.product !== "core" && parsed.product !== "pro")
|
|
533
|
+
return null;
|
|
534
|
+
if (parsed.target !== "core" && parsed.target !== "control-plane" && parsed.target !== "app-data")
|
|
535
|
+
return null;
|
|
536
|
+
if (typeof parsed.createdAt !== "string" || !parsed.createdAt)
|
|
537
|
+
return null;
|
|
538
|
+
return {
|
|
539
|
+
format: 1,
|
|
540
|
+
product: parsed.product,
|
|
541
|
+
target: parsed.target,
|
|
542
|
+
createdAt: parsed.createdAt,
|
|
543
|
+
...(typeof parsed.organizationId === "string" ? { organizationId: parsed.organizationId } : {})
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
export function validateCoreBackupSql(sql) {
|
|
551
|
+
const metadata = parseBackupMetadata(sql);
|
|
552
|
+
if (!metadata)
|
|
553
|
+
return;
|
|
554
|
+
if (metadata.product !== "core" || metadata.target !== "core") {
|
|
555
|
+
throw new Error(`Backup SQL targets ${metadata.product}/${metadata.target}; expected core/core`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
export function validateCoreBackupKey(key) {
|
|
559
|
+
const normalized = key.trim();
|
|
560
|
+
if (!normalized.startsWith(coreBackupPrefix) ||
|
|
561
|
+
(!normalized.endsWith(".sql") && !normalized.endsWith(".rbb")) ||
|
|
562
|
+
normalized.includes("..")) {
|
|
563
|
+
throw new Error("Backup key is outside the Core backup prefix");
|
|
564
|
+
}
|
|
565
|
+
return normalized;
|
|
566
|
+
}
|
|
567
|
+
function manifestObject(value) {
|
|
568
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
569
|
+
return null;
|
|
570
|
+
return value;
|
|
571
|
+
}
|
|
572
|
+
function normalizeManifestCommon(value) {
|
|
573
|
+
const entry = manifestObject(value);
|
|
574
|
+
if (!entry)
|
|
575
|
+
return null;
|
|
576
|
+
if (typeof entry.key !== "string" ||
|
|
577
|
+
!entry.key ||
|
|
578
|
+
entry.key.includes("..") ||
|
|
579
|
+
typeof entry.date !== "string" ||
|
|
580
|
+
!Number.isFinite(Date.parse(entry.date)) ||
|
|
581
|
+
typeof entry.sha256 !== "string" ||
|
|
582
|
+
!/^[a-f0-9]{64}$/.test(entry.sha256) ||
|
|
583
|
+
typeof entry.bytes !== "number" ||
|
|
584
|
+
!Number.isSafeInteger(entry.bytes) ||
|
|
585
|
+
entry.bytes <= 0 ||
|
|
586
|
+
(entry.target !== "core" && entry.target !== "control-plane" && entry.target !== "app-data") ||
|
|
587
|
+
(entry.product !== "core" && entry.product !== "pro") ||
|
|
588
|
+
(entry.trigger !== "scheduled" && entry.trigger !== "manual")) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
key: entry.key,
|
|
593
|
+
date: entry.date,
|
|
594
|
+
sha256: entry.sha256,
|
|
595
|
+
bytes: entry.bytes,
|
|
596
|
+
target: entry.target,
|
|
597
|
+
product: entry.product,
|
|
598
|
+
...(typeof entry.organizationId === "string" ? { organizationId: entry.organizationId } : {}),
|
|
599
|
+
...(typeof entry.organizationSlug === "string" ? { organizationSlug: entry.organizationSlug } : {}),
|
|
600
|
+
...(typeof entry.actorId === "string" || entry.actorId === null ? { actorId: entry.actorId } : {}),
|
|
601
|
+
trigger: entry.trigger
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Normalize both historical version-1 entries and version-2 entries into the
|
|
606
|
+
* discriminated archive model. Version-1 data gains only explicit routing
|
|
607
|
+
* facts (`archiveFormat`, `encrypted`, and `contentType`), never invented key
|
|
608
|
+
* or encryption metadata.
|
|
609
|
+
*/
|
|
610
|
+
export function normalizeBackupManifestEntry(value) {
|
|
611
|
+
const common = normalizeManifestCommon(value);
|
|
612
|
+
const entry = manifestObject(value);
|
|
613
|
+
if (!common || !entry)
|
|
614
|
+
return null;
|
|
615
|
+
if (entry.archiveFormat === 2 || entry.encrypted === true) {
|
|
616
|
+
if (entry.archiveFormat !== 2 ||
|
|
617
|
+
entry.encrypted !== true ||
|
|
618
|
+
entry.contentType !== backupBinaryContentType ||
|
|
619
|
+
typeof entry.archiveId !== "string" ||
|
|
620
|
+
typeof entry.targetId !== "string" ||
|
|
621
|
+
typeof entry.providerId !== "string" ||
|
|
622
|
+
typeof entry.kekKeyId !== "string") {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
const header = decodeBackupArchiveHeader(encodeBackupArchiveHeader({
|
|
627
|
+
format: 2,
|
|
628
|
+
archiveId: entry.archiveId,
|
|
629
|
+
product: common.product,
|
|
630
|
+
target: common.target,
|
|
631
|
+
targetId: entry.targetId,
|
|
632
|
+
createdAt: common.date,
|
|
633
|
+
logical: entry.logical,
|
|
634
|
+
encryption: entry.encryption,
|
|
635
|
+
keyEnvelope: entry.keyEnvelope
|
|
636
|
+
})).header;
|
|
637
|
+
if (header.keyEnvelope.providerId !== entry.providerId ||
|
|
638
|
+
header.keyEnvelope.kekKeyId !== entry.kekKeyId) {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
...common,
|
|
643
|
+
archiveFormat: 2,
|
|
644
|
+
archiveId: entry.archiveId,
|
|
645
|
+
targetId: entry.targetId,
|
|
646
|
+
encrypted: true,
|
|
647
|
+
contentType: backupBinaryContentType,
|
|
648
|
+
encryption: header.encryption,
|
|
649
|
+
logical: header.logical,
|
|
650
|
+
providerId: entry.providerId,
|
|
651
|
+
kekKeyId: entry.kekKeyId,
|
|
652
|
+
keyEnvelope: header.keyEnvelope
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if ((entry.archiveFormat !== undefined && entry.archiveFormat !== 1) ||
|
|
660
|
+
(entry.encrypted !== undefined && entry.encrypted !== false) ||
|
|
661
|
+
(entry.contentType !== undefined && entry.contentType !== backupSqlContentType)) {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
...common,
|
|
666
|
+
archiveFormat: 1,
|
|
667
|
+
encrypted: false,
|
|
668
|
+
contentType: backupSqlContentType
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function equalBytes(left, right) {
|
|
672
|
+
return left.byteLength === right.byteLength && left.every((value, index) => value === right[index]);
|
|
673
|
+
}
|
|
674
|
+
export function assertBackupManifestIdentity(entry, header) {
|
|
675
|
+
if (entry.archiveId !== header.archiveId ||
|
|
676
|
+
entry.product !== header.product ||
|
|
677
|
+
entry.target !== header.target ||
|
|
678
|
+
entry.targetId !== header.targetId ||
|
|
679
|
+
entry.date !== header.createdAt ||
|
|
680
|
+
entry.providerId !== header.keyEnvelope.providerId ||
|
|
681
|
+
entry.kekKeyId !== header.keyEnvelope.kekKeyId ||
|
|
682
|
+
!equalBytes(canonicalBackupJson({
|
|
683
|
+
encryption: entry.encryption,
|
|
684
|
+
logical: entry.logical,
|
|
685
|
+
keyEnvelope: entry.keyEnvelope
|
|
686
|
+
}), canonicalBackupJson({
|
|
687
|
+
encryption: header.encryption,
|
|
688
|
+
logical: header.logical,
|
|
689
|
+
keyEnvelope: header.keyEnvelope
|
|
690
|
+
}))) {
|
|
691
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_IDENTITY_MISMATCH", "Backup archive header does not match its manifest entry");
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
async function readJsonObject(bucket, key) {
|
|
695
|
+
const object = await bucket.get(key);
|
|
696
|
+
if (!object)
|
|
697
|
+
return { value: null, found: false };
|
|
698
|
+
try {
|
|
699
|
+
return { value: (await object.json()), found: true };
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
return {
|
|
703
|
+
value: null,
|
|
704
|
+
found: true,
|
|
705
|
+
warning: `Could not parse ${key}: ${error instanceof Error ? error.message : String(error)}`
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async function writeJsonObject(bucket, key, value) {
|
|
710
|
+
await bucket.put(key, `${JSON.stringify(value, null, 2)}\n`, {
|
|
711
|
+
httpMetadata: { contentType: "application/json; charset=utf-8" }
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function emptyTargetManifest(descriptor, updatedAt) {
|
|
715
|
+
return {
|
|
716
|
+
version: 2,
|
|
717
|
+
updatedAt,
|
|
718
|
+
product: descriptor.product,
|
|
719
|
+
target: descriptor.target,
|
|
720
|
+
...(descriptor.organizationId ? { organizationId: descriptor.organizationId } : {}),
|
|
721
|
+
...(descriptor.organizationSlug ? { organizationSlug: descriptor.organizationSlug } : {}),
|
|
722
|
+
sourceOfTruth: "archive-objects",
|
|
723
|
+
entries: []
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
async function readTargetManifest(bucket, descriptor, now) {
|
|
727
|
+
const read = await readJsonObject(bucket, descriptor.manifestKey);
|
|
728
|
+
if (!read.value) {
|
|
729
|
+
return {
|
|
730
|
+
manifest: emptyTargetManifest(descriptor, now),
|
|
731
|
+
needsRepair: true,
|
|
732
|
+
...(read.warning ? { warning: read.warning } : {})
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
const manifest = manifestObject(read.value);
|
|
736
|
+
const rawEntries = Array.isArray(manifest?.entries) ? manifest.entries : [];
|
|
737
|
+
const entries = rawEntries
|
|
738
|
+
.map(normalizeBackupManifestEntry)
|
|
739
|
+
.filter((entry) => Boolean(entry));
|
|
740
|
+
const structurallyValid = Boolean(manifest &&
|
|
741
|
+
(manifest.version === 1 || manifest.version === 2) &&
|
|
742
|
+
manifest.product === descriptor.product &&
|
|
743
|
+
manifest.target === descriptor.target &&
|
|
744
|
+
(manifest.organizationId ?? "") === (descriptor.organizationId ?? "") &&
|
|
745
|
+
Array.isArray(manifest.entries));
|
|
746
|
+
const invalidEntries = rawEntries.length - entries.length;
|
|
747
|
+
const warning = [
|
|
748
|
+
read.warning,
|
|
749
|
+
!structurallyValid ? `${descriptor.manifestKey} has invalid target-manifest metadata.` : undefined,
|
|
750
|
+
invalidEntries > 0 ? `${descriptor.manifestKey} contains ${invalidEntries} invalid ${invalidEntries === 1 ? "entry" : "entries"}.` : undefined
|
|
751
|
+
].filter(Boolean).join(" ") || undefined;
|
|
752
|
+
return {
|
|
753
|
+
manifest: {
|
|
754
|
+
version: 2,
|
|
755
|
+
updatedAt: typeof manifest?.updatedAt === "string" && Number.isFinite(Date.parse(manifest.updatedAt)) ? manifest.updatedAt : now,
|
|
756
|
+
product: descriptor.product,
|
|
757
|
+
target: descriptor.target,
|
|
758
|
+
...(descriptor.organizationId ? { organizationId: descriptor.organizationId } : {}),
|
|
759
|
+
...(descriptor.organizationSlug ? { organizationSlug: descriptor.organizationSlug } : {}),
|
|
760
|
+
sourceOfTruth: "archive-objects",
|
|
761
|
+
entries
|
|
762
|
+
},
|
|
763
|
+
needsRepair: !read.found || !structurallyValid || invalidEntries > 0 || manifest?.version !== 2 || manifest?.sourceOfTruth !== "archive-objects",
|
|
764
|
+
...(warning ? { warning } : {})
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function emptyRootManifest(updatedAt) {
|
|
768
|
+
return { version: 2, updatedAt, sourceOfTruth: "archive-objects", lastSuccessfulFullRunAt: null, partialRun: null, targets: [] };
|
|
769
|
+
}
|
|
770
|
+
function normalizeRootTarget(value) {
|
|
771
|
+
const target = manifestObject(value);
|
|
772
|
+
if (!target)
|
|
773
|
+
return null;
|
|
774
|
+
if ((target.product !== "core" && target.product !== "pro") ||
|
|
775
|
+
(target.target !== "core" && target.target !== "control-plane" && target.target !== "app-data") ||
|
|
776
|
+
typeof target.manifestKey !== "string" ||
|
|
777
|
+
!target.manifestKey.startsWith("backups/") ||
|
|
778
|
+
!target.manifestKey.endsWith("/manifest.json") ||
|
|
779
|
+
target.manifestKey.includes("..") ||
|
|
780
|
+
(target.latestBackupAt !== undefined && target.latestBackupAt !== null &&
|
|
781
|
+
(typeof target.latestBackupAt !== "string" || !Number.isFinite(Date.parse(target.latestBackupAt))))) {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
785
|
+
product: target.product,
|
|
786
|
+
target: target.target,
|
|
787
|
+
...(typeof target.organizationId === "string" ? { organizationId: target.organizationId } : {}),
|
|
788
|
+
...(typeof target.organizationSlug === "string" ? { organizationSlug: target.organizationSlug } : {}),
|
|
789
|
+
manifestKey: target.manifestKey,
|
|
790
|
+
...(target.latestBackupAt === null || typeof target.latestBackupAt === "string"
|
|
791
|
+
? { latestBackupAt: target.latestBackupAt }
|
|
792
|
+
: {})
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
function normalizeRootPartialRun(value) {
|
|
796
|
+
if (value === null || value === undefined)
|
|
797
|
+
return null;
|
|
798
|
+
const partial = manifestObject(value);
|
|
799
|
+
if (!partial ||
|
|
800
|
+
typeof partial.runId !== "string" ||
|
|
801
|
+
typeof partial.startedAt !== "string" ||
|
|
802
|
+
!Number.isFinite(Date.parse(partial.startedAt)) ||
|
|
803
|
+
typeof partial.stoppedAt !== "string" ||
|
|
804
|
+
!Number.isFinite(Date.parse(partial.stoppedAt)) ||
|
|
805
|
+
!Number.isSafeInteger(partial.completedTargets) ||
|
|
806
|
+
partial.completedTargets < 0 ||
|
|
807
|
+
!Number.isSafeInteger(partial.remainingTargets) ||
|
|
808
|
+
partial.remainingTargets < 0 ||
|
|
809
|
+
partial.reason !== "wall-clock-budget") {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
runId: partial.runId,
|
|
814
|
+
startedAt: partial.startedAt,
|
|
815
|
+
stoppedAt: partial.stoppedAt,
|
|
816
|
+
completedTargets: partial.completedTargets,
|
|
817
|
+
remainingTargets: partial.remainingTargets,
|
|
818
|
+
reason: "wall-clock-budget"
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
async function readRootManifest(bucket, now) {
|
|
822
|
+
const read = await readJsonObject(bucket, backupRootManifestKey);
|
|
823
|
+
if (!read.value) {
|
|
824
|
+
return {
|
|
825
|
+
manifest: emptyRootManifest(now),
|
|
826
|
+
needsRepair: true,
|
|
827
|
+
...(read.warning ? { warning: read.warning } : {})
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
const manifest = manifestObject(read.value);
|
|
831
|
+
const rawTargets = Array.isArray(manifest?.targets) ? manifest.targets : [];
|
|
832
|
+
const targets = rawTargets
|
|
833
|
+
.map(normalizeRootTarget)
|
|
834
|
+
.filter((target) => Boolean(target));
|
|
835
|
+
const partialRun = normalizeRootPartialRun(manifest?.partialRun);
|
|
836
|
+
const structurallyValid = Boolean(manifest &&
|
|
837
|
+
manifest.version === 2 &&
|
|
838
|
+
manifest.sourceOfTruth === "archive-objects" &&
|
|
839
|
+
Array.isArray(manifest.targets) &&
|
|
840
|
+
(manifest.partialRun === null || manifest.partialRun === undefined || partialRun));
|
|
841
|
+
const invalidTargets = rawTargets.length - targets.length;
|
|
842
|
+
const warning = [
|
|
843
|
+
read.warning,
|
|
844
|
+
!structurallyValid ? `${backupRootManifestKey} has invalid root-manifest metadata.` : undefined,
|
|
845
|
+
invalidTargets > 0 ? `${backupRootManifestKey} contains ${invalidTargets} invalid target ${invalidTargets === 1 ? "entry" : "entries"}.` : undefined
|
|
846
|
+
].filter(Boolean).join(" ") || undefined;
|
|
847
|
+
return {
|
|
848
|
+
manifest: {
|
|
849
|
+
version: 2,
|
|
850
|
+
updatedAt: typeof manifest?.updatedAt === "string" && Number.isFinite(Date.parse(manifest.updatedAt)) ? manifest.updatedAt : now,
|
|
851
|
+
sourceOfTruth: "archive-objects",
|
|
852
|
+
lastSuccessfulFullRunAt: typeof manifest?.lastSuccessfulFullRunAt === "string" && Number.isFinite(Date.parse(manifest.lastSuccessfulFullRunAt))
|
|
853
|
+
? manifest.lastSuccessfulFullRunAt
|
|
854
|
+
: null,
|
|
855
|
+
partialRun,
|
|
856
|
+
targets
|
|
857
|
+
},
|
|
858
|
+
needsRepair: !read.found || !structurallyValid || invalidTargets > 0,
|
|
859
|
+
...(warning ? { warning } : {})
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
function upsertRootTarget(root, descriptor, latestBackupAt) {
|
|
863
|
+
const targets = root.targets.filter((target) => !(target.product === descriptor.product &&
|
|
864
|
+
target.target === descriptor.target &&
|
|
865
|
+
(target.organizationId ?? "") === (descriptor.organizationId ?? "")));
|
|
866
|
+
targets.push({
|
|
867
|
+
product: descriptor.product,
|
|
868
|
+
target: descriptor.target,
|
|
869
|
+
...(descriptor.organizationId ? { organizationId: descriptor.organizationId } : {}),
|
|
870
|
+
...(descriptor.organizationSlug ? { organizationSlug: descriptor.organizationSlug } : {}),
|
|
871
|
+
manifestKey: descriptor.manifestKey,
|
|
872
|
+
latestBackupAt
|
|
873
|
+
});
|
|
874
|
+
targets.sort((left, right) => `${left.target}:${left.organizationId ?? ""}`.localeCompare(`${right.target}:${right.organizationId ?? ""}`));
|
|
875
|
+
return { ...root, targets };
|
|
876
|
+
}
|
|
877
|
+
function verifyBackupObject(entry, object) {
|
|
878
|
+
if (!object)
|
|
879
|
+
throw new Error(`Backup R2 object was not found after upload: ${entry.key}`);
|
|
880
|
+
if (object.httpMetadata?.contentType !== entry.contentType) {
|
|
881
|
+
throw new Error(`Backup R2 object content type mismatch for ${entry.key}`);
|
|
882
|
+
}
|
|
883
|
+
const metadata = object.customMetadata ?? {};
|
|
884
|
+
const expected = {
|
|
885
|
+
"raybase-product": entry.product,
|
|
886
|
+
"raybase-target": entry.target,
|
|
887
|
+
"raybase-backup-date": entry.date,
|
|
888
|
+
"raybase-archive-format": String(entry.archiveFormat),
|
|
889
|
+
"raybase-encrypted": String(entry.encrypted),
|
|
890
|
+
"raybase-trigger": entry.trigger
|
|
891
|
+
};
|
|
892
|
+
if (entry.archiveFormat === 2) {
|
|
893
|
+
expected["raybase-archive-id"] = entry.archiveId;
|
|
894
|
+
expected["raybase-provider-id"] = entry.providerId;
|
|
895
|
+
expected["raybase-kek-key-id"] = entry.kekKeyId;
|
|
896
|
+
}
|
|
897
|
+
if (entry.organizationId)
|
|
898
|
+
expected["raybase-organization-id"] = entry.organizationId;
|
|
899
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
900
|
+
if (metadata[key] !== value)
|
|
901
|
+
throw new Error(`Backup R2 object metadata mismatch for ${key}`);
|
|
902
|
+
}
|
|
903
|
+
if (typeof object.size === "number" && object.size !== entry.bytes) {
|
|
904
|
+
throw new Error(`Backup R2 object size mismatch for ${entry.key}`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function backupObjectMetadata(entry) {
|
|
908
|
+
return {
|
|
909
|
+
"raybase-product": entry.product,
|
|
910
|
+
"raybase-target": entry.target,
|
|
911
|
+
"raybase-backup-date": entry.date,
|
|
912
|
+
"raybase-archive-format": String(entry.archiveFormat),
|
|
913
|
+
"raybase-encrypted": String(entry.encrypted),
|
|
914
|
+
"raybase-trigger": entry.trigger,
|
|
915
|
+
...(entry.archiveFormat === 2
|
|
916
|
+
? {
|
|
917
|
+
"raybase-archive-id": entry.archiveId,
|
|
918
|
+
"raybase-provider-id": entry.providerId,
|
|
919
|
+
"raybase-kek-key-id": entry.kekKeyId
|
|
920
|
+
}
|
|
921
|
+
: {})
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
function sameBytes(left, right) {
|
|
925
|
+
return left.byteLength === right.byteLength && left.every((byte, index) => byte === right[index]);
|
|
926
|
+
}
|
|
927
|
+
function deferredValue() {
|
|
928
|
+
let resolve;
|
|
929
|
+
let reject;
|
|
930
|
+
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
931
|
+
resolve = resolvePromise;
|
|
932
|
+
reject = rejectPromise;
|
|
933
|
+
});
|
|
934
|
+
return { promise, resolve, reject };
|
|
935
|
+
}
|
|
936
|
+
function snapshotLogicalStream(stub, operationId, consistency) {
|
|
937
|
+
const totals = deferredValue();
|
|
938
|
+
// The encryptor subscribes when it emits its terminal frame. A snapshot can
|
|
939
|
+
// fail earlier, however, so register a rejection observer immediately while
|
|
940
|
+
// preserving the rejected promise for that later await.
|
|
941
|
+
void totals.promise.catch(() => undefined);
|
|
942
|
+
const decoder = new BackupLogicalDecoder();
|
|
943
|
+
const digest = createBackupSha256Digest();
|
|
944
|
+
const mode = consistency?.mode === "write-gated" ? "write-gated" : "optimistic";
|
|
945
|
+
const descriptorPromise = stub.beginBackupSnapshot({ operationId, mode });
|
|
946
|
+
let descriptor = null;
|
|
947
|
+
let cursor = null;
|
|
948
|
+
let buffered = [];
|
|
949
|
+
let recordCount = 0;
|
|
950
|
+
let rowCount = 0;
|
|
951
|
+
let tableCount = 0;
|
|
952
|
+
let completed = false;
|
|
953
|
+
const acceptLogicalBytes = async (chunk) => {
|
|
954
|
+
await digest.write(chunk);
|
|
955
|
+
for (const record of decoder.push(chunk)) {
|
|
956
|
+
recordCount += 1;
|
|
957
|
+
if (record.kind === "row")
|
|
958
|
+
rowCount += 1;
|
|
959
|
+
if (record.kind === "table-end")
|
|
960
|
+
tableCount += 1;
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
const cancel = async (reason) => {
|
|
964
|
+
if (completed)
|
|
965
|
+
return;
|
|
966
|
+
completed = true;
|
|
967
|
+
await digest.abort(reason);
|
|
968
|
+
await stub.cancelBackupSnapshot({ operationId }).catch(() => undefined);
|
|
969
|
+
totals.reject(reason);
|
|
970
|
+
};
|
|
971
|
+
return {
|
|
972
|
+
totals: totals.promise,
|
|
973
|
+
stream: new ReadableStream({
|
|
974
|
+
async pull(controller) {
|
|
975
|
+
try {
|
|
976
|
+
descriptor ??= await descriptorPromise;
|
|
977
|
+
cursor ??= descriptor.cursor;
|
|
978
|
+
if (buffered.length > 0) {
|
|
979
|
+
controller.enqueue(buffered.shift());
|
|
980
|
+
if (completed && buffered.length === 0)
|
|
981
|
+
controller.close();
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (completed) {
|
|
985
|
+
controller.close();
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (descriptor.recommendedMode === "small") {
|
|
989
|
+
const small = await stub.readBackupSnapshotSmall({
|
|
990
|
+
operationId,
|
|
991
|
+
epoch: descriptor.epoch,
|
|
992
|
+
generation: descriptor.generation,
|
|
993
|
+
schemaFingerprint: descriptor.schemaFingerprint,
|
|
994
|
+
maxBytes: backupDatabaseProtocolLimits.smallSnapshotLogicalBytes
|
|
995
|
+
});
|
|
996
|
+
const expectedEnd = await stub.finishBackupSnapshot({
|
|
997
|
+
operationId,
|
|
998
|
+
epoch: descriptor.epoch,
|
|
999
|
+
generation: descriptor.generation,
|
|
1000
|
+
schemaFingerprint: descriptor.schemaFingerprint,
|
|
1001
|
+
recordCount: small.recordCount,
|
|
1002
|
+
rowCount: small.rowCount,
|
|
1003
|
+
tableCount: small.tableCount,
|
|
1004
|
+
plaintextSha256: small.plaintextSha256
|
|
1005
|
+
});
|
|
1006
|
+
const actualEnd = small.records.at(-1);
|
|
1007
|
+
if (!actualEnd || !sameBytes(actualEnd, expectedEnd.record)) {
|
|
1008
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Small snapshot logical end is inconsistent");
|
|
1009
|
+
}
|
|
1010
|
+
for (const record of small.records.slice(0, -1))
|
|
1011
|
+
await acceptLogicalBytes(record);
|
|
1012
|
+
const digestBytes = await digest.finish();
|
|
1013
|
+
const plaintextSha256 = backupBytesToHex(digestBytes);
|
|
1014
|
+
digestBytes.fill(0);
|
|
1015
|
+
if (plaintextSha256 !== small.plaintextSha256 ||
|
|
1016
|
+
recordCount !== small.recordCount ||
|
|
1017
|
+
rowCount !== small.rowCount ||
|
|
1018
|
+
tableCount !== small.tableCount) {
|
|
1019
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Small snapshot totals are inconsistent");
|
|
1020
|
+
}
|
|
1021
|
+
decoder.push(actualEnd);
|
|
1022
|
+
decoder.finish();
|
|
1023
|
+
buffered = small.records;
|
|
1024
|
+
completed = true;
|
|
1025
|
+
totals.resolve({ recordCount, rowCount, tableCount });
|
|
1026
|
+
controller.enqueue(buffered.shift());
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const page = await stub.readBackupSnapshotPage({
|
|
1030
|
+
operationId,
|
|
1031
|
+
epoch: descriptor.epoch,
|
|
1032
|
+
generation: descriptor.generation,
|
|
1033
|
+
schemaFingerprint: descriptor.schemaFingerprint,
|
|
1034
|
+
cursor,
|
|
1035
|
+
maxBytes: backupDatabaseProtocolLimits.defaultPageBytes,
|
|
1036
|
+
maxRows: backupDatabaseProtocolLimits.defaultPageRows
|
|
1037
|
+
});
|
|
1038
|
+
cursor = page.nextCursor;
|
|
1039
|
+
for (const chunk of page.chunks)
|
|
1040
|
+
await acceptLogicalBytes(chunk);
|
|
1041
|
+
buffered = page.chunks;
|
|
1042
|
+
if (page.done) {
|
|
1043
|
+
const digestBytes = await digest.finish();
|
|
1044
|
+
const plaintextSha256 = backupBytesToHex(digestBytes);
|
|
1045
|
+
digestBytes.fill(0);
|
|
1046
|
+
const end = await stub.finishBackupSnapshot({
|
|
1047
|
+
operationId,
|
|
1048
|
+
epoch: descriptor.epoch,
|
|
1049
|
+
generation: descriptor.generation,
|
|
1050
|
+
schemaFingerprint: descriptor.schemaFingerprint,
|
|
1051
|
+
recordCount,
|
|
1052
|
+
rowCount,
|
|
1053
|
+
tableCount,
|
|
1054
|
+
plaintextSha256
|
|
1055
|
+
});
|
|
1056
|
+
decoder.push(end.record);
|
|
1057
|
+
decoder.finish();
|
|
1058
|
+
buffered.push(end.record);
|
|
1059
|
+
completed = true;
|
|
1060
|
+
totals.resolve({ recordCount, rowCount, tableCount });
|
|
1061
|
+
}
|
|
1062
|
+
if (buffered.length > 0)
|
|
1063
|
+
controller.enqueue(buffered.shift());
|
|
1064
|
+
if (completed && buffered.length === 0)
|
|
1065
|
+
controller.close();
|
|
1066
|
+
}
|
|
1067
|
+
catch (error) {
|
|
1068
|
+
await cancel(error);
|
|
1069
|
+
controller.error(error);
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
async cancel(reason) {
|
|
1073
|
+
await cancel(reason ?? new RaybaseBackupError("BACKUP_ARCHIVE_TRUNCATED", "Backup snapshot stream was cancelled"));
|
|
1074
|
+
}
|
|
1075
|
+
})
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
function validateCoreArchiveHeader(header) {
|
|
1079
|
+
if (header.product !== "core" || header.target !== "core" || header.targetId !== "core") {
|
|
1080
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_IDENTITY_MISMATCH", `Backup targets ${header.product}/${header.target}/${header.targetId}; expected core/core/core`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
async function pruneEntries(bucket, entries, retention) {
|
|
1084
|
+
const kept = entries.slice(0, retention);
|
|
1085
|
+
const pruned = entries.slice(retention);
|
|
1086
|
+
for (const entry of pruned) {
|
|
1087
|
+
await bucket.delete(entry.key);
|
|
1088
|
+
}
|
|
1089
|
+
return { kept, pruned };
|
|
1090
|
+
}
|
|
1091
|
+
export async function pruneTemporaryBackupObjects(bucket, options = {}) {
|
|
1092
|
+
const now = options.now ?? new Date();
|
|
1093
|
+
const retentionMs = options.retentionMs ?? backupTemporaryRetentionMs;
|
|
1094
|
+
if (!Number.isFinite(retentionMs) || retentionMs < 0) {
|
|
1095
|
+
throw new Error("Temporary backup retention must be a non-negative number of milliseconds");
|
|
1096
|
+
}
|
|
1097
|
+
const cutoff = now.getTime() - retentionMs;
|
|
1098
|
+
const deleted = [];
|
|
1099
|
+
let cursor;
|
|
1100
|
+
do {
|
|
1101
|
+
const page = await bucket.list({
|
|
1102
|
+
prefix: backupTemporaryPrefix,
|
|
1103
|
+
...(cursor ? { cursor } : {})
|
|
1104
|
+
});
|
|
1105
|
+
const expired = page.objects
|
|
1106
|
+
.filter((object) => object.uploaded.getTime() <= cutoff)
|
|
1107
|
+
.map((object) => object.key);
|
|
1108
|
+
if (expired.length) {
|
|
1109
|
+
await bucket.delete(expired);
|
|
1110
|
+
deleted.push(...expired);
|
|
1111
|
+
}
|
|
1112
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
1113
|
+
} while (cursor);
|
|
1114
|
+
return deleted;
|
|
1115
|
+
}
|
|
1116
|
+
function coreDescriptor() {
|
|
1117
|
+
return { product: "core", target: "core", manifestKey: coreBackupManifestKey, prefix: coreBackupPrefix };
|
|
1118
|
+
}
|
|
1119
|
+
function archiveDiscoveryWarning(warnings, warning) {
|
|
1120
|
+
if (warnings.length < backupArchiveDiscoveryMaximumWarnings)
|
|
1121
|
+
warnings.push(warning);
|
|
1122
|
+
}
|
|
1123
|
+
function targetArchiveObjectKey(descriptor, key) {
|
|
1124
|
+
if (!key.startsWith(descriptor.prefix))
|
|
1125
|
+
return false;
|
|
1126
|
+
const basename = key.slice(descriptor.prefix.length);
|
|
1127
|
+
return Boolean(basename) && !basename.includes("/") && (basename.endsWith(".rbb") || basename.endsWith(".sql"));
|
|
1128
|
+
}
|
|
1129
|
+
function expectedEncryptedArchiveKey(descriptor, identity) {
|
|
1130
|
+
return `${descriptor.prefix}${backupTimestamp(new Date(identity.createdAt))}-${identity.archiveId}.rbb`;
|
|
1131
|
+
}
|
|
1132
|
+
function entryMatchesDescriptor(entry, descriptor) {
|
|
1133
|
+
if (entry.product !== descriptor.product ||
|
|
1134
|
+
entry.target !== descriptor.target ||
|
|
1135
|
+
(entry.organizationId ?? "") !== (descriptor.organizationId ?? "") ||
|
|
1136
|
+
!targetArchiveObjectKey(descriptor, entry.key)) {
|
|
1137
|
+
return false;
|
|
1138
|
+
}
|
|
1139
|
+
if (entry.archiveFormat === 1)
|
|
1140
|
+
return entry.key.endsWith(".sql");
|
|
1141
|
+
return entry.targetId === (descriptor.organizationId ?? descriptor.target) &&
|
|
1142
|
+
entry.key === expectedEncryptedArchiveKey(descriptor, { archiveId: entry.archiveId, createdAt: entry.date });
|
|
1143
|
+
}
|
|
1144
|
+
async function listTargetArchiveObjects(bucket, descriptor) {
|
|
1145
|
+
const objects = [];
|
|
1146
|
+
let cursor;
|
|
1147
|
+
do {
|
|
1148
|
+
const page = await bucket.list({
|
|
1149
|
+
prefix: descriptor.prefix,
|
|
1150
|
+
limit: 1_000,
|
|
1151
|
+
...(cursor ? { cursor } : {})
|
|
1152
|
+
});
|
|
1153
|
+
if (objects.length + page.objects.length > backupArchiveDiscoveryMaximumObjects) {
|
|
1154
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", `Backup archive discovery exceeds ${backupArchiveDiscoveryMaximumObjects} objects under ${descriptor.prefix}`);
|
|
1155
|
+
}
|
|
1156
|
+
objects.push(...page.objects);
|
|
1157
|
+
if (page.truncated && (!page.cursor || page.cursor === cursor)) {
|
|
1158
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup archive discovery returned an invalid cursor");
|
|
1159
|
+
}
|
|
1160
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
1161
|
+
} while (cursor);
|
|
1162
|
+
return objects.filter((object) => targetArchiveObjectKey(descriptor, object.key));
|
|
1163
|
+
}
|
|
1164
|
+
function createBackupArchiveStructureValidator() {
|
|
1165
|
+
// Discovery must work even when the historical provider/key is unavailable.
|
|
1166
|
+
// Validate the canonical authenticated-header shape, routing metadata, frame
|
|
1167
|
+
// sequence, terminal frame, and exact ciphertext digest here; restore still
|
|
1168
|
+
// performs cryptographic header/frame authentication before any DB write.
|
|
1169
|
+
let headerBuffer = new Uint8Array(backupArchiveMagic.byteLength + 4);
|
|
1170
|
+
let headerLength = 0;
|
|
1171
|
+
let headerRequired = headerBuffer.byteLength;
|
|
1172
|
+
let header = null;
|
|
1173
|
+
const frameHeader = new Uint8Array(backupFrameHeaderBytes);
|
|
1174
|
+
let frameHeaderLength = 0;
|
|
1175
|
+
let frameRemaining = 0;
|
|
1176
|
+
let frameKind = 0;
|
|
1177
|
+
let sequence = 0;
|
|
1178
|
+
let ended = false;
|
|
1179
|
+
const acceptHeaderBytes = (bytes, offset) => {
|
|
1180
|
+
const take = Math.min(bytes.byteLength - offset, headerRequired - headerLength);
|
|
1181
|
+
headerBuffer.set(bytes.subarray(offset, offset + take), headerLength);
|
|
1182
|
+
headerLength += take;
|
|
1183
|
+
if (headerLength !== headerRequired)
|
|
1184
|
+
return take;
|
|
1185
|
+
if (headerRequired === backupArchiveMagic.byteLength + 4) {
|
|
1186
|
+
for (let index = 0; index < backupArchiveMagic.byteLength; index += 1) {
|
|
1187
|
+
if (headerBuffer[index] !== backupArchiveMagic[index]) {
|
|
1188
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_VERSION_UNSUPPORTED", "Backup archive magic is invalid");
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const bodyLength = new DataView(headerBuffer.buffer, headerBuffer.byteOffset + backupArchiveMagic.byteLength, 4).getUint32(0, false);
|
|
1192
|
+
if (bodyLength > backupFormatLimits.headerBytes) {
|
|
1193
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", "Backup archive header exceeds its byte limit");
|
|
1194
|
+
}
|
|
1195
|
+
const expanded = new Uint8Array(backupArchiveMagic.byteLength + 4 + bodyLength);
|
|
1196
|
+
expanded.set(headerBuffer);
|
|
1197
|
+
headerBuffer.fill(0);
|
|
1198
|
+
headerBuffer = expanded;
|
|
1199
|
+
headerRequired = expanded.byteLength;
|
|
1200
|
+
if (headerLength !== headerRequired)
|
|
1201
|
+
return take;
|
|
1202
|
+
}
|
|
1203
|
+
const decoded = decodeBackupArchiveHeader(headerBuffer);
|
|
1204
|
+
if (decoded.bytesRead !== headerBuffer.byteLength) {
|
|
1205
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup archive header has trailing bytes");
|
|
1206
|
+
}
|
|
1207
|
+
header = decoded.header;
|
|
1208
|
+
headerBuffer.fill(0);
|
|
1209
|
+
return take;
|
|
1210
|
+
};
|
|
1211
|
+
const acceptFrameHeader = () => {
|
|
1212
|
+
if (!header)
|
|
1213
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup frame appeared before its header");
|
|
1214
|
+
const view = new DataView(frameHeader.buffer, frameHeader.byteOffset, frameHeader.byteLength);
|
|
1215
|
+
const version = frameHeader[0];
|
|
1216
|
+
const kind = frameHeader[1];
|
|
1217
|
+
const flags = view.getUint16(2, false);
|
|
1218
|
+
const encodedSequence = view.getUint32(4, false);
|
|
1219
|
+
const plaintextBytes = view.getUint32(8, false);
|
|
1220
|
+
if (version !== 1 || (kind !== backupArchiveDataFrameKind && kind !== backupArchiveFinalFrameKind) || flags !== 0) {
|
|
1221
|
+
throw new RaybaseBackupError("BACKUP_FRAME_VERSION_UNSUPPORTED", "Backup frame header is unsupported");
|
|
1222
|
+
}
|
|
1223
|
+
if (encodedSequence !== sequence) {
|
|
1224
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup frame sequence is missing, duplicated, or reordered");
|
|
1225
|
+
}
|
|
1226
|
+
if (kind === backupArchiveDataFrameKind && sequence >= 0xffff_ffff) {
|
|
1227
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", "Backup frame sequence is exhausted before the final frame");
|
|
1228
|
+
}
|
|
1229
|
+
const maximum = kind === backupArchiveFinalFrameKind
|
|
1230
|
+
? backupArchiveFinalFrameMaximumBytes
|
|
1231
|
+
: header.encryption.framePlaintextBytes;
|
|
1232
|
+
if (plaintextBytes > maximum) {
|
|
1233
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", "Backup frame exceeds its plaintext byte limit");
|
|
1234
|
+
}
|
|
1235
|
+
frameKind = kind;
|
|
1236
|
+
frameRemaining = plaintextBytes + backupFrameTagBytes;
|
|
1237
|
+
frameHeader.fill(0);
|
|
1238
|
+
frameHeaderLength = 0;
|
|
1239
|
+
};
|
|
1240
|
+
return {
|
|
1241
|
+
push(bytes) {
|
|
1242
|
+
let offset = 0;
|
|
1243
|
+
while (offset < bytes.byteLength) {
|
|
1244
|
+
if (!header) {
|
|
1245
|
+
offset += acceptHeaderBytes(bytes, offset);
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
if (frameRemaining > 0) {
|
|
1249
|
+
const take = Math.min(bytes.byteLength - offset, frameRemaining);
|
|
1250
|
+
offset += take;
|
|
1251
|
+
frameRemaining -= take;
|
|
1252
|
+
if (frameRemaining === 0) {
|
|
1253
|
+
if (frameKind === backupArchiveFinalFrameKind)
|
|
1254
|
+
ended = true;
|
|
1255
|
+
sequence += 1;
|
|
1256
|
+
}
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
if (ended) {
|
|
1260
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup archive contains bytes after its final frame");
|
|
1261
|
+
}
|
|
1262
|
+
const take = Math.min(bytes.byteLength - offset, backupFrameHeaderBytes - frameHeaderLength);
|
|
1263
|
+
frameHeader.set(bytes.subarray(offset, offset + take), frameHeaderLength);
|
|
1264
|
+
frameHeaderLength += take;
|
|
1265
|
+
offset += take;
|
|
1266
|
+
if (frameHeaderLength === backupFrameHeaderBytes)
|
|
1267
|
+
acceptFrameHeader();
|
|
1268
|
+
}
|
|
1269
|
+
},
|
|
1270
|
+
finish() {
|
|
1271
|
+
if (!header)
|
|
1272
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_TRUNCATED", "Backup archive header is truncated");
|
|
1273
|
+
if (frameHeaderLength > 0 || frameRemaining > 0) {
|
|
1274
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_TRUNCATED", "Backup archive frame is truncated");
|
|
1275
|
+
}
|
|
1276
|
+
if (!ended)
|
|
1277
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_TRUNCATED", "Backup archive final frame is missing");
|
|
1278
|
+
return header;
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
async function reconstructEncryptedBackupEntry(bucket, descriptor, listed, options = {}) {
|
|
1283
|
+
const object = await bucket.get(listed.key);
|
|
1284
|
+
if (!object)
|
|
1285
|
+
throw new Error("Backup object disappeared during manifest reconstruction");
|
|
1286
|
+
if (object.size <= 0 || object.size > backupMultipartMaximumObjectBytes) {
|
|
1287
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", "Backup object size is outside the supported encrypted archive bounds");
|
|
1288
|
+
}
|
|
1289
|
+
const digest = createBackupSha256Digest();
|
|
1290
|
+
const structure = createBackupArchiveStructureValidator();
|
|
1291
|
+
const reader = object.body.getReader();
|
|
1292
|
+
let bytes = 0;
|
|
1293
|
+
let sha256;
|
|
1294
|
+
try {
|
|
1295
|
+
while (true) {
|
|
1296
|
+
const result = await reader.read();
|
|
1297
|
+
if (result.done)
|
|
1298
|
+
break;
|
|
1299
|
+
const chunk = result.value;
|
|
1300
|
+
bytes += chunk.byteLength;
|
|
1301
|
+
if (bytes > backupMultipartMaximumObjectBytes) {
|
|
1302
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", "Backup object exceeds the supported encrypted archive size");
|
|
1303
|
+
}
|
|
1304
|
+
await digest.write(chunk);
|
|
1305
|
+
structure.push(chunk);
|
|
1306
|
+
}
|
|
1307
|
+
const digestBytes = await digest.finish();
|
|
1308
|
+
sha256 = backupBytesToHex(digestBytes);
|
|
1309
|
+
digestBytes.fill(0);
|
|
1310
|
+
}
|
|
1311
|
+
catch (error) {
|
|
1312
|
+
await digest.abort(error);
|
|
1313
|
+
throw error;
|
|
1314
|
+
}
|
|
1315
|
+
finally {
|
|
1316
|
+
reader.releaseLock();
|
|
1317
|
+
}
|
|
1318
|
+
if (bytes !== object.size || bytes !== listed.size) {
|
|
1319
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup object size changed during manifest reconstruction");
|
|
1320
|
+
}
|
|
1321
|
+
const header = structure.finish();
|
|
1322
|
+
if (options.expectedIntegrity &&
|
|
1323
|
+
(bytes !== options.expectedIntegrity.bytes || sha256 !== options.expectedIntegrity.sha256)) {
|
|
1324
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup object ciphertext integrity contradicts its existing manifest entry");
|
|
1325
|
+
}
|
|
1326
|
+
if (header.product !== descriptor.product ||
|
|
1327
|
+
header.target !== descriptor.target ||
|
|
1328
|
+
header.targetId !== (descriptor.organizationId ?? descriptor.target)) {
|
|
1329
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_IDENTITY_MISMATCH", `Backup object targets ${header.product}/${header.target}/${header.targetId}, not this manifest target`);
|
|
1330
|
+
}
|
|
1331
|
+
if (listed.key !== expectedEncryptedArchiveKey(descriptor, header)) {
|
|
1332
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_IDENTITY_MISMATCH", "Backup object key does not match its archive header");
|
|
1333
|
+
}
|
|
1334
|
+
const trigger = object.customMetadata?.["raybase-trigger"];
|
|
1335
|
+
if (trigger !== "manual" && trigger !== "scheduled") {
|
|
1336
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup object trigger metadata is invalid");
|
|
1337
|
+
}
|
|
1338
|
+
const entry = {
|
|
1339
|
+
key: listed.key,
|
|
1340
|
+
date: header.createdAt,
|
|
1341
|
+
sha256,
|
|
1342
|
+
bytes,
|
|
1343
|
+
product: header.product,
|
|
1344
|
+
target: header.target,
|
|
1345
|
+
...(descriptor.organizationId ? { organizationId: descriptor.organizationId } : {}),
|
|
1346
|
+
...(descriptor.organizationSlug ? { organizationSlug: descriptor.organizationSlug } : {}),
|
|
1347
|
+
trigger,
|
|
1348
|
+
archiveFormat: 2,
|
|
1349
|
+
archiveId: header.archiveId,
|
|
1350
|
+
targetId: header.targetId,
|
|
1351
|
+
encrypted: true,
|
|
1352
|
+
contentType: backupBinaryContentType,
|
|
1353
|
+
encryption: header.encryption,
|
|
1354
|
+
logical: header.logical,
|
|
1355
|
+
providerId: header.keyEnvelope.providerId,
|
|
1356
|
+
kekKeyId: header.keyEnvelope.kekKeyId,
|
|
1357
|
+
keyEnvelope: header.keyEnvelope
|
|
1358
|
+
};
|
|
1359
|
+
verifyBackupObject(entry, object);
|
|
1360
|
+
const recordedProviderAvailable = [options.providers?.keyProvider, options.providers?.deploymentProvider]
|
|
1361
|
+
.some((provider) => provider?.id === header.keyEnvelope.providerId);
|
|
1362
|
+
if (recordedProviderAvailable && options.providers) {
|
|
1363
|
+
const authenticationObject = await bucket.get(listed.key);
|
|
1364
|
+
if (!authenticationObject)
|
|
1365
|
+
throw new Error("Backup object disappeared during authenticated manifest reconstruction");
|
|
1366
|
+
const decryptor = createBackupArchiveDecryptor({
|
|
1367
|
+
providers: options.providers,
|
|
1368
|
+
...(options.environment ? { environment: options.environment } : {}),
|
|
1369
|
+
expectedCiphertextBytes: bytes,
|
|
1370
|
+
expectedCiphertextSha256: sha256,
|
|
1371
|
+
validateHeader(candidate) {
|
|
1372
|
+
if (!sameBytes(encodeBackupArchiveHeader(candidate), encodeBackupArchiveHeader(header))) {
|
|
1373
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_IDENTITY_MISMATCH", "Backup header changed during authenticated manifest reconstruction");
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
const authenticatedReader = authenticationObject.body.pipeThrough(decryptor.transform).getReader();
|
|
1378
|
+
try {
|
|
1379
|
+
while (!(await authenticatedReader.read()).done) {
|
|
1380
|
+
// Authentication is streaming and plaintext is deliberately discarded.
|
|
1381
|
+
}
|
|
1382
|
+
await Promise.all([decryptor.header, decryptor.completion]);
|
|
1383
|
+
}
|
|
1384
|
+
catch (error) {
|
|
1385
|
+
await Promise.allSettled([decryptor.header, decryptor.completion]);
|
|
1386
|
+
if (error instanceof RaybaseBackupError &&
|
|
1387
|
+
(error.code === "BACKUP_PROVIDER_UNAVAILABLE" ||
|
|
1388
|
+
error.code === "BACKUP_KEY_UNAVAILABLE" ||
|
|
1389
|
+
error.code === "BACKUP_THRESHOLD_UNMET")) {
|
|
1390
|
+
// Keep historical archives discoverable when their provider/key/share
|
|
1391
|
+
// material is unavailable. Restore availability reports that state.
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
throw error;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
finally {
|
|
1398
|
+
authenticatedReader.releaseLock();
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return entry;
|
|
1402
|
+
}
|
|
1403
|
+
async function readTargetCatalog(bucket, descriptor, now, options = {}) {
|
|
1404
|
+
const read = await readTargetManifest(bucket, descriptor, now);
|
|
1405
|
+
const manifestEntries = new Map(read.manifest.entries
|
|
1406
|
+
.filter((entry) => entryMatchesDescriptor(entry, descriptor))
|
|
1407
|
+
.map((entry) => [entry.key, entry]));
|
|
1408
|
+
const objects = await listTargetArchiveObjects(bucket, descriptor);
|
|
1409
|
+
const entries = [];
|
|
1410
|
+
const repairEntries = [];
|
|
1411
|
+
const warnings = read.warning ? [read.warning] : [];
|
|
1412
|
+
let ignoredWarnings = 0;
|
|
1413
|
+
for (const object of objects.sort((left, right) => left.key.localeCompare(right.key))) {
|
|
1414
|
+
const existing = manifestEntries.get(object.key);
|
|
1415
|
+
try {
|
|
1416
|
+
if (object.key.endsWith(".sql")) {
|
|
1417
|
+
const legacy = existing;
|
|
1418
|
+
if (!legacy || legacy.archiveFormat !== 1)
|
|
1419
|
+
continue;
|
|
1420
|
+
verifyBackupObject(legacy, await bucket.head(object.key));
|
|
1421
|
+
entries.push(legacy);
|
|
1422
|
+
repairEntries.push(legacy);
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
const reconstructed = await reconstructEncryptedBackupEntry(bucket, descriptor, object, {
|
|
1426
|
+
...options,
|
|
1427
|
+
...(existing?.archiveFormat === 2
|
|
1428
|
+
? { expectedIntegrity: { bytes: existing.bytes, sha256: existing.sha256 } }
|
|
1429
|
+
: {})
|
|
1430
|
+
});
|
|
1431
|
+
if (existing?.archiveFormat === 2 &&
|
|
1432
|
+
existing.bytes === reconstructed.bytes &&
|
|
1433
|
+
existing.sha256 === reconstructed.sha256) {
|
|
1434
|
+
assertBackupManifestIdentity(existing, decodeBackupArchiveHeader(encodeBackupArchiveHeader({
|
|
1435
|
+
format: 2,
|
|
1436
|
+
archiveId: reconstructed.archiveId,
|
|
1437
|
+
product: reconstructed.product,
|
|
1438
|
+
target: reconstructed.target,
|
|
1439
|
+
targetId: reconstructed.targetId,
|
|
1440
|
+
createdAt: reconstructed.date,
|
|
1441
|
+
logical: reconstructed.logical,
|
|
1442
|
+
encryption: reconstructed.encryption,
|
|
1443
|
+
keyEnvelope: reconstructed.keyEnvelope
|
|
1444
|
+
})).header);
|
|
1445
|
+
const normalized = {
|
|
1446
|
+
...reconstructed,
|
|
1447
|
+
...(existing.actorId !== undefined ? { actorId: existing.actorId } : {})
|
|
1448
|
+
};
|
|
1449
|
+
entries.push(normalized);
|
|
1450
|
+
repairEntries.push(normalized);
|
|
1451
|
+
}
|
|
1452
|
+
else {
|
|
1453
|
+
entries.push(reconstructed);
|
|
1454
|
+
repairEntries.push(reconstructed);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
catch (error) {
|
|
1458
|
+
if (existing)
|
|
1459
|
+
repairEntries.push(existing);
|
|
1460
|
+
const warning = `Ignored invalid backup object ${object.key}: ${error instanceof Error ? error.message : String(error)}`;
|
|
1461
|
+
if (warnings.length < backupArchiveDiscoveryMaximumWarnings)
|
|
1462
|
+
archiveDiscoveryWarning(warnings, warning);
|
|
1463
|
+
else
|
|
1464
|
+
ignoredWarnings += 1;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
if (ignoredWarnings > 0)
|
|
1468
|
+
warnings.push(`Ignored ${ignoredWarnings} additional invalid backup objects.`);
|
|
1469
|
+
entries.sort((left, right) => right.date.localeCompare(left.date) || left.key.localeCompare(right.key));
|
|
1470
|
+
repairEntries.sort((left, right) => right.date.localeCompare(left.date) || left.key.localeCompare(right.key));
|
|
1471
|
+
const needsRepair = read.needsRepair || JSON.stringify(repairEntries) !== JSON.stringify(read.manifest.entries);
|
|
1472
|
+
return {
|
|
1473
|
+
manifest: {
|
|
1474
|
+
...read.manifest,
|
|
1475
|
+
updatedAt: needsRepair ? now : read.manifest.updatedAt,
|
|
1476
|
+
entries: repairEntries
|
|
1477
|
+
},
|
|
1478
|
+
entries,
|
|
1479
|
+
needsRepair,
|
|
1480
|
+
...(warnings.length ? { warning: warnings.join(" ") } : {})
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
async function readAndRepairCoreBackupCatalog(bucket, now, options = {}) {
|
|
1484
|
+
const descriptor = coreDescriptor();
|
|
1485
|
+
const target = await readTargetCatalog(bucket, descriptor, now, options);
|
|
1486
|
+
const warnings = target.warning ? [target.warning] : [];
|
|
1487
|
+
if (target.needsRepair) {
|
|
1488
|
+
try {
|
|
1489
|
+
await writeJsonObject(bucket, descriptor.manifestKey, target.manifest);
|
|
1490
|
+
}
|
|
1491
|
+
catch (error) {
|
|
1492
|
+
warnings.push(`Could not repair ${descriptor.manifestKey}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
const rootRead = await readRootManifest(bucket, now);
|
|
1496
|
+
if (rootRead.warning)
|
|
1497
|
+
warnings.push(rootRead.warning);
|
|
1498
|
+
const root = upsertRootTarget(rootRead.manifest, descriptor, target.entries[0]?.date ?? null);
|
|
1499
|
+
const rootNeedsRepair = rootRead.needsRepair || JSON.stringify(root.targets) !== JSON.stringify(rootRead.manifest.targets);
|
|
1500
|
+
const repairedRoot = {
|
|
1501
|
+
...root,
|
|
1502
|
+
updatedAt: rootNeedsRepair ? now : root.updatedAt
|
|
1503
|
+
};
|
|
1504
|
+
if (rootNeedsRepair) {
|
|
1505
|
+
try {
|
|
1506
|
+
await writeJsonObject(bucket, backupRootManifestKey, repairedRoot);
|
|
1507
|
+
}
|
|
1508
|
+
catch (error) {
|
|
1509
|
+
warnings.push(`Could not repair ${backupRootManifestKey}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return {
|
|
1513
|
+
entries: target.entries,
|
|
1514
|
+
root: repairedRoot,
|
|
1515
|
+
...(warnings.length ? { warning: warnings.join(" ") } : {})
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
export async function listCoreBackupEntries(env, backups, environment) {
|
|
1519
|
+
if (!env.RAYBASE_BACKUPS)
|
|
1520
|
+
throw new Error("RAYBASE_BACKUPS R2 binding is missing");
|
|
1521
|
+
let providers = {};
|
|
1522
|
+
try {
|
|
1523
|
+
providers = await backupProviderSet(env, backups);
|
|
1524
|
+
}
|
|
1525
|
+
catch {
|
|
1526
|
+
// Listing remains available when provider configuration is invalid; such
|
|
1527
|
+
// archives receive structural validation and explicit restore readiness.
|
|
1528
|
+
}
|
|
1529
|
+
const catalog = await readAndRepairCoreBackupCatalog(env.RAYBASE_BACKUPS, new Date().toISOString(), {
|
|
1530
|
+
providers,
|
|
1531
|
+
...(environment ?? env.RAYBASE_ENV ? { environment: environment ?? env.RAYBASE_ENV } : {})
|
|
1532
|
+
});
|
|
1533
|
+
return { entries: catalog.entries, ...(catalog.warning ? { warning: catalog.warning } : {}) };
|
|
1534
|
+
}
|
|
1535
|
+
export async function readBackupRestoreAvailability(env, entry, backups, runtimeStatus) {
|
|
1536
|
+
const restore = runtimeStatus ?? await readCoreBackupRestoreRuntimeStatus(env);
|
|
1537
|
+
if (restore.maintenance?.active) {
|
|
1538
|
+
return {
|
|
1539
|
+
available: false,
|
|
1540
|
+
reason: `Restore maintenance is active (${restore.maintenance.phase ?? "unknown phase"}).`
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
if (entry.archiveFormat === 1) {
|
|
1544
|
+
return {
|
|
1545
|
+
available: entry.bytes <= backupLegacyUploadMaximumBytes,
|
|
1546
|
+
...(entry.bytes > backupLegacyUploadMaximumBytes
|
|
1547
|
+
? { reason: `Legacy plaintext restore exceeds the ${backupLegacyUploadMaximumBytes}-byte compatibility limit.` }
|
|
1548
|
+
: {})
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
if (!restore.pitrAvailable) {
|
|
1552
|
+
return {
|
|
1553
|
+
available: false,
|
|
1554
|
+
reason: `Encrypted restore requires production PITR recovery support. ${restore.pitrUnavailableReason ?? "PITR is unavailable."}`
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
try {
|
|
1558
|
+
const providers = await backupProviderSet(env, backups);
|
|
1559
|
+
const provider = [providers.keyProvider, providers.deploymentProvider]
|
|
1560
|
+
.find((candidate) => candidate?.id === entry.providerId);
|
|
1561
|
+
if (!provider) {
|
|
1562
|
+
return { available: false, reason: `Backup provider ${entry.providerId} is unavailable.` };
|
|
1563
|
+
}
|
|
1564
|
+
const context = {
|
|
1565
|
+
archiveId: entry.archiveId,
|
|
1566
|
+
product: entry.product,
|
|
1567
|
+
target: entry.target,
|
|
1568
|
+
targetId: entry.targetId,
|
|
1569
|
+
createdAt: entry.date,
|
|
1570
|
+
purpose: "readiness",
|
|
1571
|
+
signal: new AbortController().signal,
|
|
1572
|
+
...(env.RAYBASE_ENV ? { environment: env.RAYBASE_ENV } : {})
|
|
1573
|
+
};
|
|
1574
|
+
if (provider.passwordSecrets) {
|
|
1575
|
+
const participantStatus = await passwordParticipantReadiness(env, provider, context, false);
|
|
1576
|
+
if (participantStatus && !participantStatus.readyForNewBackups) {
|
|
1577
|
+
return {
|
|
1578
|
+
available: false,
|
|
1579
|
+
reason: participantStatus.reason ?? "Password-secret participants are not ready."
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
const dek = await unwrapBackupDek({ providers, context, envelope: entry.keyEnvelope });
|
|
1584
|
+
dek.fill(0);
|
|
1585
|
+
return { available: true };
|
|
1586
|
+
}
|
|
1587
|
+
catch (error) {
|
|
1588
|
+
if (error instanceof RaybaseBackupError && error.code === "BACKUP_KEY_UNAVAILABLE") {
|
|
1589
|
+
return { available: false, reason: `Historical key ${entry.kekKeyId} is unavailable.` };
|
|
1590
|
+
}
|
|
1591
|
+
if (error instanceof RaybaseBackupError) {
|
|
1592
|
+
return { available: false, reason: error.message };
|
|
1593
|
+
}
|
|
1594
|
+
return {
|
|
1595
|
+
available: false,
|
|
1596
|
+
reason: "Backup restore readiness could not be determined."
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
export async function readCoreBackupStatus(env, options = {}) {
|
|
1601
|
+
const retention = parseBackupRetention(env.RAYBASE_BACKUPS_RETAIN);
|
|
1602
|
+
const enabled = backupsEnabled(env);
|
|
1603
|
+
const consistencyPolicy = options.backups?.consistency?.mode === "write-gated" ? "write-gated" : "optimistic";
|
|
1604
|
+
const restore = await readCoreBackupRestoreRuntimeStatus(env);
|
|
1605
|
+
const caveats = [
|
|
1606
|
+
"New backups are envelope-encrypted format-2 archives; SHA-256 covers the stored ciphertext.",
|
|
1607
|
+
"Backups include Durable Object table data and Raybase file metadata.",
|
|
1608
|
+
...(options.storageConfigured ? ["R2 file bytes are not included."] : []),
|
|
1609
|
+
...(options.vectorizeConfigured ? ["Vectorize indexes are not included."] : ["Vectorize indexes are not included when configured."]),
|
|
1610
|
+
"Legacy plaintext format-1 SQL is restore-only and keeps its historical memory and statement-size limits.",
|
|
1611
|
+
...(consistencyPolicy === "write-gated"
|
|
1612
|
+
? ["The write-gated consistency policy temporarily rejects database mutations while a backup snapshot is read."]
|
|
1613
|
+
: ["Hot databases are retried optimistically and fail closed when a consistent generation cannot be read."]),
|
|
1614
|
+
...(restore.pitrAvailable
|
|
1615
|
+
? ["Encrypted restores use a persistent maintenance gate and production PITR undo bookmark for interrupted-restore recovery."]
|
|
1616
|
+
: [`Encrypted restore is unavailable: ${restore.pitrUnavailableReason ?? "production PITR recovery support is unavailable."}`]),
|
|
1617
|
+
...(restore.maintenance?.active
|
|
1618
|
+
? [`Restore maintenance is active in ${restore.maintenance.phase ?? "an unknown"} phase; normal database access remains gated until rollback recovery completes.`]
|
|
1619
|
+
: [])
|
|
1620
|
+
];
|
|
1621
|
+
let encryptionReady = false;
|
|
1622
|
+
let provider = {
|
|
1623
|
+
id: null,
|
|
1624
|
+
mode: null,
|
|
1625
|
+
keyId: null,
|
|
1626
|
+
passwordCoupled: false
|
|
1627
|
+
};
|
|
1628
|
+
let encryptionWarning;
|
|
1629
|
+
let catalogProviders = {};
|
|
1630
|
+
try {
|
|
1631
|
+
const providers = await backupProviderSet(env, options.backups);
|
|
1632
|
+
catalogProviders = providers;
|
|
1633
|
+
const selected = selectedBackupProvider(providers);
|
|
1634
|
+
if (selected) {
|
|
1635
|
+
const context = backupReadinessContext(options.environment ?? env.RAYBASE_ENV);
|
|
1636
|
+
const participants = await passwordParticipantReadiness(env, selected, context, false);
|
|
1637
|
+
const envelope = participants?.readyForNewBackups === false
|
|
1638
|
+
? null
|
|
1639
|
+
: await probeBackupProviderForCreation(env, providers, context);
|
|
1640
|
+
provider = {
|
|
1641
|
+
id: selected.id,
|
|
1642
|
+
mode: selected.mode,
|
|
1643
|
+
keyId: envelope?.kekKeyId ?? null,
|
|
1644
|
+
passwordCoupled: Boolean(selected.passwordSecrets),
|
|
1645
|
+
...(selected.passwordSecrets ? { passwordLifecycleVersion: selected.passwordSecrets.version } : {}),
|
|
1646
|
+
...(participants ? { passwordParticipants: participants } : {})
|
|
1647
|
+
};
|
|
1648
|
+
encryptionReady = participants?.readyForNewBackups ?? true;
|
|
1649
|
+
if (participants && !participants.readyForNewBackups) {
|
|
1650
|
+
encryptionWarning = participants.reason ?? "Password-secret participants are not ready for backup creation.";
|
|
1651
|
+
}
|
|
1652
|
+
if (selected.passwordSecrets) {
|
|
1653
|
+
caveats.push("This app provider couples restore access to password-secret participant readiness; passwordless users may be ineligible, and password reset or loss can permanently destroy access to older protected shares.");
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
else {
|
|
1657
|
+
encryptionWarning = "Encrypted backup creation requires an app key provider or RAYBASE_BACKUPS_KEY.";
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
catch (error) {
|
|
1661
|
+
encryptionWarning = error instanceof RaybaseBackupError
|
|
1662
|
+
? error.message
|
|
1663
|
+
: "Backup encryption provider readiness failed.";
|
|
1664
|
+
}
|
|
1665
|
+
if (!env.RAYBASE_BACKUPS) {
|
|
1666
|
+
return {
|
|
1667
|
+
enabled,
|
|
1668
|
+
bindingPresent: false,
|
|
1669
|
+
encryptionReady,
|
|
1670
|
+
archiveFormat: 2,
|
|
1671
|
+
consistencyPolicy,
|
|
1672
|
+
provider,
|
|
1673
|
+
restore,
|
|
1674
|
+
retention,
|
|
1675
|
+
rootManifestKey: backupRootManifestKey,
|
|
1676
|
+
targets: [],
|
|
1677
|
+
latestBackup: null,
|
|
1678
|
+
caveats,
|
|
1679
|
+
...(encryptionWarning ? { warning: encryptionWarning } : {})
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
const now = new Date().toISOString();
|
|
1683
|
+
const catalog = await readAndRepairCoreBackupCatalog(env.RAYBASE_BACKUPS, now, {
|
|
1684
|
+
providers: catalogProviders,
|
|
1685
|
+
...(options.environment ?? env.RAYBASE_ENV ? { environment: options.environment ?? env.RAYBASE_ENV } : {})
|
|
1686
|
+
});
|
|
1687
|
+
return {
|
|
1688
|
+
enabled,
|
|
1689
|
+
bindingPresent: true,
|
|
1690
|
+
encryptionReady,
|
|
1691
|
+
archiveFormat: 2,
|
|
1692
|
+
consistencyPolicy,
|
|
1693
|
+
provider,
|
|
1694
|
+
restore,
|
|
1695
|
+
retention,
|
|
1696
|
+
rootManifestKey: backupRootManifestKey,
|
|
1697
|
+
targets: catalog.root.targets,
|
|
1698
|
+
latestBackup: catalog.entries[0] ?? null,
|
|
1699
|
+
caveats,
|
|
1700
|
+
...(catalog.warning || encryptionWarning
|
|
1701
|
+
? { warning: [catalog.warning, encryptionWarning].filter(Boolean).join(" ") }
|
|
1702
|
+
: {})
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
export async function readCoreBackupSqlFromR2(env, key) {
|
|
1706
|
+
if (!env.RAYBASE_BACKUPS)
|
|
1707
|
+
throw new Error("RAYBASE_BACKUPS R2 binding is missing");
|
|
1708
|
+
const normalized = validateCoreBackupKey(key);
|
|
1709
|
+
if (!normalized.endsWith(".sql"))
|
|
1710
|
+
throw new Error("Encrypted backup archives are binary, not SQL text");
|
|
1711
|
+
const object = await env.RAYBASE_BACKUPS.get(normalized);
|
|
1712
|
+
if (!object)
|
|
1713
|
+
throw new Error("Backup object not found");
|
|
1714
|
+
if (object.size > backupLegacyUploadMaximumBytes) {
|
|
1715
|
+
throw new RaybaseBackupError("BACKUP_LIMIT_EXCEEDED", `Legacy plaintext restores are limited to ${backupLegacyUploadMaximumBytes} bytes`);
|
|
1716
|
+
}
|
|
1717
|
+
const sql = new TextDecoder("utf-8", { fatal: true }).decode(await object.arrayBuffer());
|
|
1718
|
+
validateCoreBackupSql(sql);
|
|
1719
|
+
return { key: normalized, sql, object };
|
|
1720
|
+
}
|
|
1721
|
+
export async function readCoreBackupObjectFromR2(env, key, backups, environment) {
|
|
1722
|
+
if (!env.RAYBASE_BACKUPS)
|
|
1723
|
+
throw new Error("RAYBASE_BACKUPS R2 binding is missing");
|
|
1724
|
+
const normalized = validateCoreBackupKey(key);
|
|
1725
|
+
const listed = await listCoreBackupEntries(env, backups, environment);
|
|
1726
|
+
const entry = listed.entries.find((candidate) => candidate.key === normalized);
|
|
1727
|
+
if (!entry)
|
|
1728
|
+
throw new Error("Backup manifest entry not found");
|
|
1729
|
+
const object = await env.RAYBASE_BACKUPS.get(normalized);
|
|
1730
|
+
verifyBackupObject(entry, object);
|
|
1731
|
+
if (!object)
|
|
1732
|
+
throw new Error("Backup object not found");
|
|
1733
|
+
return { key: normalized, entry, object };
|
|
1734
|
+
}
|
|
1735
|
+
async function consumeEncryptedBackup(object, providers, options) {
|
|
1736
|
+
const decryptor = createBackupArchiveDecryptor({
|
|
1737
|
+
providers,
|
|
1738
|
+
...(options.providerInput ? { providerInput: options.providerInput } : {}),
|
|
1739
|
+
...(options.environment ? { environment: options.environment } : {}),
|
|
1740
|
+
...(options.expectedCiphertextBytes !== undefined
|
|
1741
|
+
? { expectedCiphertextBytes: options.expectedCiphertextBytes }
|
|
1742
|
+
: {}),
|
|
1743
|
+
...(options.expectedCiphertextSha256
|
|
1744
|
+
? { expectedCiphertextSha256: options.expectedCiphertextSha256 }
|
|
1745
|
+
: {}),
|
|
1746
|
+
validateHeader(header) {
|
|
1747
|
+
validateCoreArchiveHeader(header);
|
|
1748
|
+
if (options.expectedEntry)
|
|
1749
|
+
assertBackupManifestIdentity(options.expectedEntry, header);
|
|
1750
|
+
if (options.expectedHeader && !sameBytes(encodeBackupArchiveHeader(options.expectedHeader), encodeBackupArchiveHeader(header))) {
|
|
1751
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_IDENTITY_MISMATCH", "Backup header changed between restore passes");
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
});
|
|
1755
|
+
const decoder = new BackupLogicalDecoder();
|
|
1756
|
+
const logicalDigest = createBackupSha256Digest();
|
|
1757
|
+
const reader = object.body.pipeThrough(decryptor.transform).getReader();
|
|
1758
|
+
let logicalEnd = null;
|
|
1759
|
+
try {
|
|
1760
|
+
while (true) {
|
|
1761
|
+
const result = await reader.read();
|
|
1762
|
+
if (result.done)
|
|
1763
|
+
break;
|
|
1764
|
+
for (const record of decoder.push(result.value)) {
|
|
1765
|
+
if (record.kind === "logical-end") {
|
|
1766
|
+
if (logicalEnd)
|
|
1767
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup has duplicate logical end records");
|
|
1768
|
+
const digestBytes = await logicalDigest.finish();
|
|
1769
|
+
const digestHex = backupBytesToHex(digestBytes);
|
|
1770
|
+
digestBytes.fill(0);
|
|
1771
|
+
if (digestHex !== record.plaintextSha256) {
|
|
1772
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup logical plaintext digest does not match");
|
|
1773
|
+
}
|
|
1774
|
+
logicalEnd = record;
|
|
1775
|
+
}
|
|
1776
|
+
else {
|
|
1777
|
+
await logicalDigest.write(encodeBackupLogicalRecord(record));
|
|
1778
|
+
}
|
|
1779
|
+
await options.onRecord?.(record);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
decoder.finish();
|
|
1783
|
+
const header = await decryptor.header;
|
|
1784
|
+
const completion = await decryptor.completion;
|
|
1785
|
+
if (!logicalEnd)
|
|
1786
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_TRUNCATED", "Backup logical end is missing");
|
|
1787
|
+
if (completion.logicalTotals.recordCount !== logicalEnd.recordCount ||
|
|
1788
|
+
completion.logicalTotals.rowCount !== logicalEnd.rowCount ||
|
|
1789
|
+
completion.logicalTotals.tableCount !== logicalEnd.tableCount) {
|
|
1790
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Authenticated frame totals do not match the logical archive");
|
|
1791
|
+
}
|
|
1792
|
+
if (!sameBytes(encodeBackupArchiveHeader(header), encodeBackupArchiveHeader(completion.header))) {
|
|
1793
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_IDENTITY_MISMATCH", "Backup header completion is inconsistent");
|
|
1794
|
+
}
|
|
1795
|
+
return { completion, logicalEnd };
|
|
1796
|
+
}
|
|
1797
|
+
catch (error) {
|
|
1798
|
+
await logicalDigest.abort(error);
|
|
1799
|
+
throw error;
|
|
1800
|
+
}
|
|
1801
|
+
finally {
|
|
1802
|
+
reader.releaseLock();
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
export async function restoreCoreEncryptedBackup(env, source, options = {}) {
|
|
1806
|
+
const providers = await backupProviderSet(env, options.backups);
|
|
1807
|
+
if (!selectedBackupProvider(providers)) {
|
|
1808
|
+
throw new RaybaseBackupError("BACKUP_ENCRYPTION_NOT_CONFIGURED", "Encrypted restore requires the provider and historical key named by the archive");
|
|
1809
|
+
}
|
|
1810
|
+
const firstObject = await source.getObject();
|
|
1811
|
+
const verified = await consumeEncryptedBackup(firstObject, providers, {
|
|
1812
|
+
...(options.providerInput ? { providerInput: options.providerInput } : {}),
|
|
1813
|
+
...(options.environment ?? env.RAYBASE_ENV ? { environment: options.environment ?? env.RAYBASE_ENV } : {}),
|
|
1814
|
+
...(source.entry ? { expectedEntry: source.entry } : {}),
|
|
1815
|
+
...(source.entry ? { expectedCiphertextBytes: source.entry.bytes, expectedCiphertextSha256: source.entry.sha256 } : {})
|
|
1816
|
+
});
|
|
1817
|
+
await prepareCoreBackupRestore(env, { requirePitr: true });
|
|
1818
|
+
const stub = dataStub(env);
|
|
1819
|
+
const operationId = crypto.randomUUID();
|
|
1820
|
+
await stub.beginBackupRestore({
|
|
1821
|
+
operationId,
|
|
1822
|
+
sourceSha256: verified.completion.ciphertextSha256,
|
|
1823
|
+
archiveId: verified.completion.header.archiveId,
|
|
1824
|
+
targetId: verified.completion.header.targetId,
|
|
1825
|
+
expectedRecordCount: verified.logicalEnd.recordCount,
|
|
1826
|
+
expectedRowCount: verified.logicalEnd.rowCount,
|
|
1827
|
+
expectedTableCount: verified.logicalEnd.tableCount,
|
|
1828
|
+
plaintextSha256: verified.logicalEnd.plaintextSha256
|
|
1829
|
+
});
|
|
1830
|
+
let batch = [];
|
|
1831
|
+
let batchBytes = 0;
|
|
1832
|
+
let batchIndex = 0;
|
|
1833
|
+
const flush = async () => {
|
|
1834
|
+
if (!batch.length)
|
|
1835
|
+
return;
|
|
1836
|
+
const encoded = batch.map(encodeBackupLogicalRecord);
|
|
1837
|
+
const joined = new Uint8Array(encoded.reduce((total, record) => total + record.byteLength, 0));
|
|
1838
|
+
let offset = 0;
|
|
1839
|
+
for (const record of encoded) {
|
|
1840
|
+
joined.set(record, offset);
|
|
1841
|
+
offset += record.byteLength;
|
|
1842
|
+
}
|
|
1843
|
+
const batchSha256 = await sha256Hex(joined);
|
|
1844
|
+
joined.fill(0);
|
|
1845
|
+
await stub.applyBackupRestoreBatch({ operationId, batchIndex, batchSha256, records: batch });
|
|
1846
|
+
batch = [];
|
|
1847
|
+
batchBytes = 0;
|
|
1848
|
+
batchIndex += 1;
|
|
1849
|
+
};
|
|
1850
|
+
try {
|
|
1851
|
+
const secondObject = await source.getObject();
|
|
1852
|
+
await consumeEncryptedBackup(secondObject, providers, {
|
|
1853
|
+
...(options.providerInput ? { providerInput: options.providerInput } : {}),
|
|
1854
|
+
...(options.environment ?? env.RAYBASE_ENV ? { environment: options.environment ?? env.RAYBASE_ENV } : {}),
|
|
1855
|
+
expectedHeader: verified.completion.header,
|
|
1856
|
+
expectedCiphertextBytes: verified.completion.ciphertextBytes,
|
|
1857
|
+
expectedCiphertextSha256: verified.completion.ciphertextSha256,
|
|
1858
|
+
async onRecord(record) {
|
|
1859
|
+
const encodedBytes = encodeBackupLogicalRecord(record).byteLength;
|
|
1860
|
+
if (batch.length > 0 &&
|
|
1861
|
+
(batch.length >= backupDatabaseProtocolLimits.maximumRestoreBatchRecords ||
|
|
1862
|
+
batchBytes + encodedBytes > backupDatabaseProtocolLimits.maximumRestoreBatchBytes)) {
|
|
1863
|
+
await flush();
|
|
1864
|
+
}
|
|
1865
|
+
batch.push(record);
|
|
1866
|
+
batchBytes += encodedBytes;
|
|
1867
|
+
if (batch.length >= backupDatabaseProtocolLimits.maximumRestoreBatchRecords ||
|
|
1868
|
+
batchBytes >= backupDatabaseProtocolLimits.maximumRestoreBatchBytes) {
|
|
1869
|
+
await flush();
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
await flush();
|
|
1874
|
+
await stub.finishBackupRestore({ operationId });
|
|
1875
|
+
}
|
|
1876
|
+
catch (error) {
|
|
1877
|
+
await stub.failBackupRestore({
|
|
1878
|
+
operationId,
|
|
1879
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1880
|
+
}).catch(() => undefined);
|
|
1881
|
+
try {
|
|
1882
|
+
await recoverInterruptedBackupRestore({ stub });
|
|
1883
|
+
}
|
|
1884
|
+
catch (recoveryError) {
|
|
1885
|
+
throw new RaybaseBackupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", `Restore failed and automatic PITR rollback could not complete: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`);
|
|
1886
|
+
}
|
|
1887
|
+
throw error;
|
|
1888
|
+
}
|
|
1889
|
+
return {
|
|
1890
|
+
ok: true,
|
|
1891
|
+
archiveFormat: 2,
|
|
1892
|
+
encrypted: true,
|
|
1893
|
+
archiveId: verified.completion.header.archiveId,
|
|
1894
|
+
sourceSha256: verified.completion.ciphertextSha256,
|
|
1895
|
+
recordCount: verified.logicalEnd.recordCount,
|
|
1896
|
+
rowCount: verified.logicalEnd.rowCount,
|
|
1897
|
+
tableCount: verified.logicalEnd.tableCount
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
export async function restoreCoreEncryptedBackupFromR2(env, key, options = {}) {
|
|
1901
|
+
const stored = await readCoreBackupObjectFromR2(env, key, options.backups, options.environment);
|
|
1902
|
+
if (stored.entry.archiveFormat !== 2)
|
|
1903
|
+
throw new Error("Legacy SQL backups use the compatibility restore path");
|
|
1904
|
+
return restoreCoreEncryptedBackup(env, {
|
|
1905
|
+
entry: stored.entry,
|
|
1906
|
+
async getObject() {
|
|
1907
|
+
if (!env.RAYBASE_BACKUPS)
|
|
1908
|
+
throw new Error("RAYBASE_BACKUPS R2 binding is missing");
|
|
1909
|
+
const object = await env.RAYBASE_BACKUPS.get(stored.key);
|
|
1910
|
+
if (!object)
|
|
1911
|
+
throw new Error("Backup object not found");
|
|
1912
|
+
return object;
|
|
1913
|
+
}
|
|
1914
|
+
}, options);
|
|
1915
|
+
}
|
|
1916
|
+
export async function archiveBackupToR2(env, options = {}) {
|
|
1917
|
+
if (!backupsEnabled(env)) {
|
|
1918
|
+
console.info("Raybase backup archive skipped", { reason: "disabled" });
|
|
1919
|
+
return { ok: true, skipped: true, reason: "disabled", archived: false };
|
|
1920
|
+
}
|
|
1921
|
+
if (!env.RAYBASE_BACKUPS) {
|
|
1922
|
+
console.info("Raybase backup archive skipped", { reason: "missing-r2-binding" });
|
|
1923
|
+
return { ok: true, skipped: true, reason: "missing-r2-binding", archived: false };
|
|
1924
|
+
}
|
|
1925
|
+
await pruneTemporaryBackupObjects(env.RAYBASE_BACKUPS, { now: options.now }).catch((error) => {
|
|
1926
|
+
console.warn("Temporary encrypted backup cleanup failed", error);
|
|
1927
|
+
});
|
|
1928
|
+
const providers = await backupProviderSet(env, options.backups);
|
|
1929
|
+
if (!selectedBackupProvider(providers)) {
|
|
1930
|
+
throw new RaybaseBackupError("BACKUP_ENCRYPTION_NOT_CONFIGURED", "Encrypted backups require an app key provider or RAYBASE_BACKUPS_KEY");
|
|
1931
|
+
}
|
|
1932
|
+
const now = options.now ?? new Date();
|
|
1933
|
+
const date = now.toISOString();
|
|
1934
|
+
const trigger = options.trigger ?? "scheduled";
|
|
1935
|
+
const descriptor = coreDescriptor();
|
|
1936
|
+
const maxAttempts = options.backups?.consistency?.mode === "write-gated"
|
|
1937
|
+
? 1
|
|
1938
|
+
: options.backups?.consistency?.maxAttempts;
|
|
1939
|
+
const entry = await retryConsistentBackupSnapshot(async () => {
|
|
1940
|
+
const archiveId = crypto.randomUUID();
|
|
1941
|
+
const key = `${descriptor.prefix}${backupTimestamp(now)}-${archiveId}.rbb`;
|
|
1942
|
+
const identity = {
|
|
1943
|
+
archiveId,
|
|
1944
|
+
product: "core",
|
|
1945
|
+
target: "core",
|
|
1946
|
+
targetId: "core",
|
|
1947
|
+
createdAt: date
|
|
1948
|
+
};
|
|
1949
|
+
const wrapContext = {
|
|
1950
|
+
...identity,
|
|
1951
|
+
purpose: "wrap",
|
|
1952
|
+
signal: new AbortController().signal,
|
|
1953
|
+
...(options.environment ?? env.RAYBASE_ENV ? { environment: options.environment ?? env.RAYBASE_ENV } : {})
|
|
1954
|
+
};
|
|
1955
|
+
const wrapParticipants = await resolvePasswordParticipantsForWrap(env, selectedBackupProvider(providers), wrapContext);
|
|
1956
|
+
let snapshot;
|
|
1957
|
+
let encryptor;
|
|
1958
|
+
try {
|
|
1959
|
+
encryptor = await createBackupArchiveEncryptor({
|
|
1960
|
+
identity,
|
|
1961
|
+
providers,
|
|
1962
|
+
participants: wrapParticipants.participants,
|
|
1963
|
+
logicalTotals: () => {
|
|
1964
|
+
if (!snapshot)
|
|
1965
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Backup snapshot did not initialize");
|
|
1966
|
+
return snapshot.totals;
|
|
1967
|
+
},
|
|
1968
|
+
...(options.environment ?? env.RAYBASE_ENV ? { environment: options.environment ?? env.RAYBASE_ENV } : {})
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
finally {
|
|
1972
|
+
wrapParticipants.release();
|
|
1973
|
+
}
|
|
1974
|
+
const provisional = {
|
|
1975
|
+
key,
|
|
1976
|
+
date,
|
|
1977
|
+
sha256: "0".repeat(64),
|
|
1978
|
+
bytes: 0,
|
|
1979
|
+
product: "core",
|
|
1980
|
+
target: "core",
|
|
1981
|
+
actorId: options.actorId ?? null,
|
|
1982
|
+
trigger,
|
|
1983
|
+
archiveFormat: 2,
|
|
1984
|
+
archiveId,
|
|
1985
|
+
targetId: "core",
|
|
1986
|
+
encrypted: true,
|
|
1987
|
+
contentType: backupBinaryContentType,
|
|
1988
|
+
encryption: encryptor.header.encryption,
|
|
1989
|
+
logical: encryptor.header.logical,
|
|
1990
|
+
providerId: encryptor.header.keyEnvelope.providerId,
|
|
1991
|
+
kekKeyId: encryptor.header.keyEnvelope.kekKeyId,
|
|
1992
|
+
keyEnvelope: encryptor.header.keyEnvelope
|
|
1993
|
+
};
|
|
1994
|
+
const upload = await env.RAYBASE_BACKUPS.createMultipartUpload(key, {
|
|
1995
|
+
httpMetadata: { contentType: backupBinaryContentType },
|
|
1996
|
+
customMetadata: backupObjectMetadata(provisional)
|
|
1997
|
+
});
|
|
1998
|
+
snapshot = snapshotLogicalStream(dataStub(env), archiveId, options.backups?.consistency);
|
|
1999
|
+
const ciphertext = snapshot.stream.pipeThrough(encryptor.transform);
|
|
2000
|
+
const [uploaded, completion] = await Promise.all([
|
|
2001
|
+
uploadBackupMultipartStream(upload, ciphertext),
|
|
2002
|
+
encryptor.completion
|
|
2003
|
+
]);
|
|
2004
|
+
if (uploaded.bytes !== completion.ciphertextBytes ||
|
|
2005
|
+
uploaded.sha256 !== completion.ciphertextSha256) {
|
|
2006
|
+
await env.RAYBASE_BACKUPS.delete(key).catch(() => undefined);
|
|
2007
|
+
throw new RaybaseBackupError("BACKUP_ARCHIVE_CORRUPT", "Uploaded backup bytes do not match encryption completion evidence");
|
|
2008
|
+
}
|
|
2009
|
+
return {
|
|
2010
|
+
...provisional,
|
|
2011
|
+
bytes: uploaded.bytes,
|
|
2012
|
+
sha256: uploaded.sha256
|
|
2013
|
+
};
|
|
2014
|
+
}, { ...(maxAttempts ? { maxAttempts } : {}) });
|
|
2015
|
+
verifyBackupObject(entry, await env.RAYBASE_BACKUPS.head(entry.key));
|
|
2016
|
+
const catalog = await readTargetCatalog(env.RAYBASE_BACKUPS, descriptor, date, {
|
|
2017
|
+
providers,
|
|
2018
|
+
...(options.environment ?? env.RAYBASE_ENV ? { environment: options.environment ?? env.RAYBASE_ENV } : {})
|
|
2019
|
+
});
|
|
2020
|
+
const operationalEntries = [entry, ...catalog.entries.filter((existing) => existing.key !== entry.key)]
|
|
2021
|
+
.sort((left, right) => right.date.localeCompare(left.date) || left.key.localeCompare(right.key));
|
|
2022
|
+
const manifestEntries = [entry, ...catalog.manifest.entries.filter((existing) => existing.key !== entry.key)]
|
|
2023
|
+
.sort((left, right) => right.date.localeCompare(left.date) || left.key.localeCompare(right.key));
|
|
2024
|
+
const retention = parseBackupRetention(env.RAYBASE_BACKUPS_RETAIN);
|
|
2025
|
+
await writeJsonObject(env.RAYBASE_BACKUPS, descriptor.manifestKey, {
|
|
2026
|
+
...catalog.manifest,
|
|
2027
|
+
updatedAt: date,
|
|
2028
|
+
entries: manifestEntries
|
|
2029
|
+
});
|
|
2030
|
+
const rootRead = await readRootManifest(env.RAYBASE_BACKUPS, date);
|
|
2031
|
+
const root = upsertRootTarget(rootRead.manifest, descriptor, entry.date);
|
|
2032
|
+
await writeJsonObject(env.RAYBASE_BACKUPS, backupRootManifestKey, {
|
|
2033
|
+
...root,
|
|
2034
|
+
updatedAt: date,
|
|
2035
|
+
lastSuccessfulFullRunAt: date,
|
|
2036
|
+
partialRun: null
|
|
2037
|
+
});
|
|
2038
|
+
const pruned = await pruneEntries(env.RAYBASE_BACKUPS, operationalEntries, retention);
|
|
2039
|
+
if (pruned.pruned.length) {
|
|
2040
|
+
const prunedKeys = new Set(pruned.pruned.map((prunedEntry) => prunedEntry.key));
|
|
2041
|
+
await writeJsonObject(env.RAYBASE_BACKUPS, descriptor.manifestKey, {
|
|
2042
|
+
...catalog.manifest,
|
|
2043
|
+
updatedAt: date,
|
|
2044
|
+
entries: manifestEntries.filter((manifestEntry) => !prunedKeys.has(manifestEntry.key))
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
await audit(raybaseAuthExecutor(env), options.actorId ?? null, `database.backup.${trigger}`, "raybase_database", undefined, {
|
|
2048
|
+
key: entry.key,
|
|
2049
|
+
bytes: entry.bytes,
|
|
2050
|
+
sha256: entry.sha256,
|
|
2051
|
+
archiveFormat: entry.archiveFormat,
|
|
2052
|
+
encrypted: entry.encrypted,
|
|
2053
|
+
providerId: entry.providerId,
|
|
2054
|
+
kekKeyId: entry.kekKeyId
|
|
2055
|
+
}).catch((error) => {
|
|
2056
|
+
console.warn("Raybase backup archive audit failed", error);
|
|
2057
|
+
});
|
|
2058
|
+
console.info("Raybase backup archive complete", {
|
|
2059
|
+
key: entry.key,
|
|
2060
|
+
bytes: entry.bytes,
|
|
2061
|
+
sha256: entry.sha256,
|
|
2062
|
+
providerId: entry.providerId,
|
|
2063
|
+
kekKeyId: entry.kekKeyId,
|
|
2064
|
+
trigger: entry.trigger
|
|
2065
|
+
});
|
|
2066
|
+
return { ok: true, skipped: false, archived: true, entry, pruned: pruned.pruned };
|
|
2067
|
+
}
|
|
2068
|
+
//# sourceMappingURL=backup-archive.js.map
|