@rebasepro/server 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{GCSStorageController-C7ARrDAp.js → GCSStorageController-40FyFeSx.js} +8 -4
- package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
- package/dist/{S3StorageController-qevJ0pVE.js → S3StorageController-Bl5shp94.js} +8 -4
- package/dist/S3StorageController-Bl5shp94.js.map +1 -0
- package/dist/access-BFAUvsSs.js +1897 -0
- package/dist/access-BFAUvsSs.js.map +1 -0
- package/dist/api/branch-routes.d.ts +25 -0
- package/dist/api/closed-database.d.ts +25 -0
- package/dist/api/live-schema-routes.d.ts +10 -0
- package/dist/api/rest/api-generator.d.ts +9 -1
- package/dist/api/rest/idempotency.d.ts +15 -1
- package/dist/api/root-error-handler.d.ts +6 -5
- package/dist/api/types.d.ts +12 -0
- package/dist/{ast-schema-editor-mCeSC03x.js → ast-schema-editor-madTa9Hh.js} +2 -2
- package/dist/{ast-schema-editor-mCeSC03x.js.map → ast-schema-editor-madTa9Hh.js.map} +1 -1
- package/dist/auth/access.d.ts +10 -0
- package/dist/auth/adapter-middleware.d.ts +2 -1
- package/dist/auth/adapter-service-key.d.ts +24 -0
- package/dist/auth/api-keys/api-key-middleware.d.ts +21 -1
- package/dist/auth/api-keys/api-key-store.d.ts +8 -1
- package/dist/auth/api-keys/key-grant.d.ts +18 -1
- package/dist/auth/expired-token-sweep.d.ts +5 -0
- package/dist/auth/impersonation.d.ts +3 -1
- package/dist/auth/jwt.d.ts +8 -2
- package/dist/auth/oauth-signin-policy.d.ts +21 -35
- package/dist/auth/rls-scope.d.ts +17 -7
- package/dist/auth/rls-simulator-routes.d.ts +51 -0
- package/dist/auth/simulation-read-only.d.ts +28 -0
- package/dist/auth/simulation.d.ts +141 -0
- package/dist/auth/sql-rate-limit-store.d.ts +15 -1
- package/dist/{auth-CteGWxsL.js → auth-izI7AEzC.js} +391 -1893
- package/dist/auth-izI7AEzC.js.map +1 -0
- package/dist/auth_adapter-B7RUMk6e.js +89 -0
- package/dist/auth_adapter-B7RUMk6e.js.map +1 -0
- package/dist/backend/boot-announcements.d.ts +13 -0
- package/dist/backend/create-backend.d.ts +10 -0
- package/dist/backend/options.d.ts +9 -4
- package/dist/{backend-DTAOsLQc.js → backend-CaG_Jyso.js} +9 -2
- package/dist/backend-CaG_Jyso.js.map +1 -0
- package/dist/{backup-Dq0lyJ_D.js → backup-BVbHoKo8.js} +8 -8
- package/dist/{backup-Dq0lyJ_D.js.map → backup-BVbHoKo8.js.map} +1 -1
- package/dist/boot/boot.d.ts +2 -15
- package/dist/boot/ddl-bootstrap.d.ts +34 -0
- package/dist/boot/env.d.ts +6 -0
- package/dist/boot/fetch-bundle.d.ts +5 -1
- package/dist/boot/provision-only.d.ts +23 -0
- package/dist/boot/provision.d.ts +53 -2
- package/dist/boot/role.d.ts +2 -0
- package/dist/boot/schema-change-recorder.d.ts +45 -0
- package/dist/boot/security-headers.d.ts +22 -0
- package/dist/branch-routes-B2vvEx53.js +84 -0
- package/dist/branch-routes-B2vvEx53.js.map +1 -0
- package/dist/{contract-routes-DiFwiLg2.js → contract-routes-CU2A3JnF.js} +3 -3
- package/dist/{contract-routes-DiFwiLg2.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
- package/dist/cron/cron-store.d.ts +8 -1
- package/dist/{cron-loader-NHqRfKF0.js → cron-loader-mMhqbVsl.js} +3 -3
- package/dist/{cron-loader-NHqRfKF0.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
- package/dist/{cron-routes-Dhf82HzT.js → cron-routes-Cs33j_Kr.js} +5 -5
- package/dist/{cron-routes-Dhf82HzT.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
- package/dist/{cron-scheduler-D1DT0nw5.js → cron-scheduler-Be7CNghL.js} +3 -3
- package/dist/{cron-scheduler-D1DT0nw5.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
- package/dist/{cron-store-DEpVstUU.js → cron-store-DLn11M8H.js} +11 -10
- package/dist/cron-store-DLn11M8H.js.map +1 -0
- package/dist/{strings.es-BeZWAodn.js → data_driver-D0mg1Y_x.js} +2 -90
- package/dist/data_driver-D0mg1Y_x.js.map +1 -0
- package/dist/{ddl-bootstrap-BNqi2NZe.js → ddl-bootstrap-CjRrn12l.js} +92 -3
- package/dist/ddl-bootstrap-CjRrn12l.js.map +1 -0
- package/dist/deploy/pod-contract.d.ts +1 -1
- package/dist/email/dev-sink.d.ts +6 -0
- package/dist/email/index.d.ts +1 -1
- package/dist/{errors-_ytoGi8A.js → errors-OMWkKSH-.js} +66 -5
- package/dist/errors-OMWkKSH-.js.map +1 -0
- package/dist/{soft-delete-params-BAgpmBvp.js → field-access-DYsvjI_P.js} +27 -57
- package/dist/field-access-DYsvjI_P.js.map +1 -0
- package/dist/{filter-dialect-O1HK6Nvr.js → filter-dialect-TrKFWLqT.js} +3 -3
- package/dist/{filter-dialect-O1HK6Nvr.js.map → filter-dialect-TrKFWLqT.js.map} +1 -1
- package/dist/{function-loader-DbCSoNJj.js → function-loader-B8YzXEsQ.js} +22 -15
- package/dist/function-loader-B8YzXEsQ.js.map +1 -0
- package/dist/{function-routes-DNV4RUF0.js → function-routes-RxrCHNuM.js} +2 -2
- package/dist/{function-routes-DNV4RUF0.js.map → function-routes-RxrCHNuM.js.map} +1 -1
- package/dist/functions/hono-like.d.ts +12 -0
- package/dist/functions/index.js +37 -2
- package/dist/functions/index.js.map +1 -1
- package/dist/functions/request-timeout.d.ts +3 -3
- package/dist/history/history-routes.d.ts +6 -0
- package/dist/history/retention-sweep.d.ts +5 -0
- package/dist/{history-recorder-RZ1Ow4YY.js → history-recorder-Bsau7h4X.js} +7 -7
- package/dist/history-recorder-Bsau7h4X.js.map +1 -0
- package/dist/{history-store-DPofGVDp.js → history-store-BQz5YCkW.js} +26 -2
- package/dist/history-store-BQz5YCkW.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.es.js +3003 -681
- package/dist/index.es.js.map +1 -1
- package/dist/init/process-safety.d.ts +4 -3
- package/dist/init/shutdown.d.ts +8 -0
- package/dist/init.d.ts +6 -1
- package/dist/jobs/job-store.d.ts +8 -1
- package/dist/{jobs-8G-_agld.js → jobs-Co6Ni5HP.js} +7 -7
- package/dist/jobs-Co6Ni5HP.js.map +1 -0
- package/dist/{jwt-YhciR93H.js → jwt-rDS0884_.js} +5 -4
- package/dist/{jwt-YhciR93H.js.map → jwt-rDS0884_.js.map} +1 -1
- package/dist/{keys-COzQ2aE8.js → keys-BXcdRNhJ.js} +2 -2
- package/dist/{keys-COzQ2aE8.js.map → keys-BXcdRNhJ.js.map} +1 -1
- package/dist/{logger-Bt32sHFw.js → logger-CW8pmTS6.js} +35 -3
- package/dist/logger-CW8pmTS6.js.map +1 -0
- package/dist/{logs-routes-CelTiS7C.js → logs-routes-CBt7mMIL.js} +3 -3
- package/dist/{logs-routes-CelTiS7C.js.map → logs-routes-CBt7mMIL.js.map} +1 -1
- package/dist/mcp/consent-page.d.ts +38 -1
- package/dist/mcp/mcp-routes.d.ts +9 -0
- package/dist/mcp/mcp-tools.d.ts +7 -1
- package/dist/mcp/oauth-store.d.ts +12 -5
- package/dist/metrics/history-recorder.d.ts +4 -1
- package/dist/metrics/history-store.d.ts +8 -0
- package/dist/{offline-BrqCRgih-BtDB1ecy.js → offline-AvuEcg7u-DFHL98OT.js} +127 -40
- package/dist/offline-AvuEcg7u-DFHL98OT.js.map +1 -0
- package/dist/{offline-store-BhYXViha-BW5j1We_.js → offline-store-BhYXViha-YTmjBpo2.js} +5 -5
- package/dist/{offline-store-BhYXViha-BW5j1We_.js.map → offline-store-BhYXViha-YTmjBpo2.js.map} +1 -1
- package/dist/{openapi-generator-BamY6-ga.js → openapi-generator-CyxEtYEX.js} +61 -22
- package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
- package/dist/{proxy-BEKbsil6.js → proxy-BYir8tWK.js} +5 -7
- package/dist/proxy-BYir8tWK.js.map +1 -0
- package/dist/{query-parser-CGohjyWI.js → query-parser-CDXWf2hH.js} +7 -6
- package/dist/{query-parser-CGohjyWI.js.map → query-parser-CDXWf2hH.js.map} +1 -1
- package/dist/{request-timeout-CRny5KD0.js → request-timeout-DVjo-c_8.js} +6 -6
- package/dist/request-timeout-DVjo-c_8.js.map +1 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js +190 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js.map +1 -0
- package/dist/schema-edit/deployed-schema-guard.d.ts +47 -0
- package/dist/{schema-editor-routes-CnNGCayU.js → schema-editor-routes-Dc3DU_jm.js} +4 -4
- package/dist/{schema-editor-routes-CnNGCayU.js.map → schema-editor-routes-Dc3DU_jm.js.map} +1 -1
- package/dist/soft-delete-params-BRONCOA2.js +59 -0
- package/dist/soft-delete-params-BRONCOA2.js.map +1 -0
- package/dist/{sql-export-routes-BgTJ7K3S.js → sql-export-routes-QycX8W8S.js} +40 -6
- package/dist/sql-export-routes-QycX8W8S.js.map +1 -0
- package/dist/{src-D5Y3N-S4.js → src-Bh9LRPb9.js} +21 -2
- package/dist/{src-D5Y3N-S4.js.map → src-Bh9LRPb9.js.map} +1 -1
- package/dist/storage/GCSStorageController.d.ts +2 -0
- package/dist/storage/LocalStorageController.d.ts +8 -3
- package/dist/storage/S3StorageController.d.ts +2 -0
- package/dist/storage/maintenance-keys.d.ts +17 -0
- package/dist/storage/maintenance-routes.d.ts +28 -5
- package/dist/storage/maintenance.d.ts +136 -4
- package/dist/storage/orphan-scans.d.ts +120 -0
- package/dist/storage/types.d.ts +11 -0
- package/dist/strings.es-B7QLnHh2.js +96 -0
- package/dist/strings.es-B7QLnHh2.js.map +1 -0
- package/dist/{upload-validation-I9cCUBIW.js → upload-validation-Bgo626uY.js} +2 -2
- package/dist/upload-validation-Bgo626uY.js.map +1 -0
- package/dist/utils/dev-dependency-command.d.ts +15 -0
- package/dist/utils/dev-port.d.ts +8 -0
- package/dist/utils/logger.d.ts +24 -0
- package/package.json +5 -5
- package/dist/GCSStorageController-C7ARrDAp.js.map +0 -1
- package/dist/S3StorageController-qevJ0pVE.js.map +0 -1
- package/dist/auth-CteGWxsL.js.map +0 -1
- package/dist/auth_adapter-CIItCTc8.js +0 -21
- package/dist/auth_adapter-CIItCTc8.js.map +0 -1
- package/dist/backend-DTAOsLQc.js.map +0 -1
- package/dist/cron-store-DEpVstUU.js.map +0 -1
- package/dist/ddl-bootstrap-BNqi2NZe.js.map +0 -1
- package/dist/errors-_ytoGi8A.js.map +0 -1
- package/dist/function-loader-DbCSoNJj.js.map +0 -1
- package/dist/history-recorder-RZ1Ow4YY.js.map +0 -1
- package/dist/history-store-DPofGVDp.js.map +0 -1
- package/dist/jobs-8G-_agld.js.map +0 -1
- package/dist/logger-Bt32sHFw.js.map +0 -1
- package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
- package/dist/openapi-generator-BamY6-ga.js.map +0 -1
- package/dist/proxy-BEKbsil6.js.map +0 -1
- package/dist/request-timeout-CRny5KD0.js.map +0 -1
- package/dist/soft-delete-params-BAgpmBvp.js.map +0 -1
- package/dist/sql-export-routes-BgTJ7K3S.js.map +0 -1
- package/dist/strings.es-BeZWAodn.js.map +0 -1
- package/dist/upload-validation-I9cCUBIW.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"backup-Dq0lyJ_D.js","names":[],"sources":["../src/backup/backup-common.ts","../src/backup/backup-storage.ts","../src/backup/backup-routes.ts","../src/backup/backup-schedule.ts","../src/backup/index.ts"],"sourcesContent":["/**\n * Storage-generic backup helpers used by the admin backup routes.\n *\n * These live in server (which owns {@link StorageController}) so the\n * admin API can list backups without depending on the Postgres driver —\n * `server` must not import from `server-postgres`. The `pg_dump`-side\n * copies of the tiny pure parsers live in `@rebasepro/server-postgres`;\n * keep the two in sync if the naming scheme ever changes.\n */\nimport fs from \"fs\";\nimport path from \"path\";\nimport type { BackupInfo, BackupDestinationKind } from \"@rebasepro/types\";\nimport type { StorageController } from \"../storage\";\nimport { logger } from \"../utils/logger\";\n\nexport type BackupDestination =\n | { kind: \"local\"; path: string }\n | { kind: \"s3\"; bucket: string; prefix: string }\n | { kind: \"gcs\"; bucket: string; prefix: string };\n\n/** Parse a destination string (`s3://…`, `gs://…`, or a local path). */\nexport function parseBackupDestination(out: string): BackupDestination {\n const s3 = out.match(/^s3:\\/\\/([^/]+)\\/?(.*)$/);\n if (s3) return { kind: \"s3\", bucket: s3[1], prefix: s3[2].replace(/\\/+$/, \"\") };\n const gcs = out.match(/^gs:\\/\\/([^/]+)\\/?(.*)$/);\n if (gcs) return { kind: \"gcs\", bucket: gcs[1], prefix: gcs[2].replace(/\\/+$/, \"\") };\n return { kind: \"local\", path: out };\n}\n\n/**\n * Recover the UTC creation time encoded in a `rebase-<db>-<ts>.dump` name.\n * Returns `null` for names that don't match, so foreign objects are ignored.\n */\nexport function parseBackupTimestamp(fileName: string): Date | null {\n const base = fileName.split(\"/\").pop() ?? fileName;\n const match = base.match(/-(\\d{8})T(\\d{6})Z\\.dump$/);\n if (!match) return null;\n const [, ymd, hms] = match;\n const iso =\n `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}` +\n `T${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}Z`;\n const date = new Date(iso);\n return Number.isNaN(date.getTime()) ? null : date;\n}\n\n/**\n * A backup is two files: the `.dump`, and beside it a `.globals.sql` holding\n * the cluster roles the dump's GRANTs and RLS policies name. Without that\n * sidecar a restore into a fresh cluster stops at the first GRANT to a role\n * that does not exist there. The CLI uploads, restores and prunes the two as\n * a pair. This listing used to know only the `.dump`, so a backup downloaded\n * from Studio arrived without its roles.\n *\n * The suffix mirrors `globalsFileForDump` in `@rebasepro/server-postgres`.\n */\nconst DUMP_SUFFIX = \".dump\";\nconst GLOBALS_SUFFIX = \".globals.sql\";\n\nfunction globalsKeyFor(dumpKey: string): string {\n return dumpKey.slice(0, -DUMP_SUFFIX.length) + GLOBALS_SUFFIX;\n}\n\n/** The only files the download route will serve. */\nfunction isBackupFile(key: string): boolean {\n return key.endsWith(DUMP_SUFFIX) || key.endsWith(GLOBALS_SUFFIX);\n}\n\n/**\n * How many pages of an object-storage listing to read before giving up — at\n * the providers' 1,000 keys a page, 100,000 objects, or 50,000 backups.\n */\nconst MAX_LIST_PAGES = 100;\n\n/**\n * Every key under a prefix, across pages.\n *\n * S3 and GCS answer a listing a page at a time, in ascending key order, and a\n * backup's key is `rebase-<db>-<UTC timestamp>`. Reading one page therefore\n * returned the OLDEST thousand objects: with retention unset and an hourly\n * schedule, the newest backup the panel and `rebase db backups list` showed\n * stopped advancing after about three weeks, which reads as \"backups stopped\".\n */\nexport async function listAllObjectKeys(\n storage: StorageController,\n prefix: string,\n bucket: string\n): Promise<string[]> {\n const keys: string[] = [];\n let pageToken: string | undefined;\n for (let page = 0; page < MAX_LIST_PAGES; page++) {\n const result = await storage.listObjects(prefix, { bucket, maxResults: 1000, pageToken });\n for (const item of result.items) keys.push(item.fullPath);\n pageToken = result.nextPageToken;\n if (!pageToken) return keys;\n }\n logger.warn(\n `[backups] Stopped listing ${bucket}/${prefix} after ${MAX_LIST_PAGES} pages (${keys.length} objects). ` +\n \"Set BACKUP_RETENTION_DAYS so old backups are pruned, or give the backups a prefix of their own.\"\n );\n return keys;\n}\n\n/**\n * Whether an object key is one the backup routes may serve: a backup file, at\n * the destination's prefix and nowhere else.\n *\n * The local branch has always been held to the backup directory; this branch\n * checked only the suffix, so any `.dump` in the bucket — `uploads/x.dump`, a\n * neighbouring `nightlyish/` prefix — was readable by naming it. A key is taken\n * as written, so a scheme (`s3://other-bucket/…`, which S3StorageController\n * would follow) or a `.`/`..`/empty segment (which a filesystem-backed or\n * S3-compatible store may resolve) is refused rather than interpreted.\n */\nfunction isObjectBackupKey(dest: { prefix: string }, key: string): boolean {\n if (!isBackupFile(key) || /^[a-z][a-z0-9+.-]*:\\/\\//i.test(key)) return false;\n const segments = key.split(\"/\");\n if (segments.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return false;\n return dest.prefix === \"\" || key.startsWith(`${dest.prefix}/`);\n}\n\n/**\n * List the backups at a destination as {@link BackupInfo}, newest first.\n * One entry per `.dump`, with its `.globals.sql` sidecar attached as\n * `globalsKey` when there is one.\n */\nexport async function listBackupObjects(\n dest: BackupDestination,\n storage?: StorageController\n): Promise<BackupInfo[]> {\n if (dest.kind === \"local\") {\n // Asynchronous throughout, a few stats at a time. This runs on the\n // Backups panel's request path, and a year of hourly backups is 8,760\n // dumps: stat'ed synchronously, the server answered nothing else\n // while it listed them.\n const stat = await fs.promises.stat(dest.path).catch(() => null);\n if (!stat) return [];\n const dir = stat.isDirectory() ? dest.path : path.dirname(dest.path);\n const files = await fs.promises.readdir(dir);\n const present = new Set(files);\n const dumps = files.filter((f) => f.endsWith(DUMP_SUFFIX));\n const stats = await mapBounded(dumps, LOCAL_STAT_CONCURRENCY, (f) => fs.promises.stat(path.join(dir, f)));\n return dumps\n .map((f, i): BackupInfo => {\n const full = path.join(dir, f);\n const st = stats[i];\n const createdAt = parseBackupTimestamp(f) ?? st.mtime;\n const globals = globalsKeyFor(f);\n return {\n key: full,\n name: f,\n sizeBytes: st.size,\n createdAt: createdAt.toISOString(),\n destinationKind: \"local\",\n ...(present.has(globals) ? { globalsKey: path.join(dir, globals) } : {})\n };\n })\n .sort(byNewest);\n }\n\n // An empty list is a claim about the bucket. Without a controller for it\n // nothing was read, and the panel said \"No backups yet.\" about a bucket\n // full of them.\n if (!storage) throw new Error(noControllerFor(dest));\n const keys = await listAllObjectKeys(storage, dest.prefix ? `${dest.prefix}/` : \"\", dest.bucket);\n const present = new Set(keys);\n return keys\n .filter((key) => key.endsWith(DUMP_SUFFIX))\n .map((key): BackupInfo => {\n const createdAt = parseBackupTimestamp(key);\n const globals = globalsKeyFor(key);\n return {\n key,\n name: key.split(\"/\").pop() || key,\n createdAt: createdAt ? createdAt.toISOString() : undefined,\n destinationKind: dest.kind as BackupDestinationKind,\n ...(present.has(globals) ? { globalsKey: globals } : {})\n };\n })\n .sort(byNewest);\n}\n\n/** Stats in flight at once while listing a local backup folder. */\nconst LOCAL_STAT_CONCURRENCY = 32;\n\n/** `fn` over `items`, at most `limit` at a time; results in input order. */\nasync function mapBounded<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {\n const results = new Array<R>(items.length);\n let next = 0;\n const worker = async () => {\n while (next < items.length) {\n const i = next++;\n results[i] = await fn(items[i]);\n }\n };\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));\n return results;\n}\n\nfunction noControllerFor(dest: { kind: \"s3\" | \"gcs\"; bucket: string; prefix: string }): string {\n const scheme = dest.kind === \"s3\" ? \"s3\" : \"gs\";\n return `No storage controller for ${scheme}://${dest.bucket}${dest.prefix ? `/${dest.prefix}` : \"\"}, so its backups cannot be read.`;\n}\n\nfunction byNewest(a: BackupInfo, b: BackupInfo): number {\n const ta = a.createdAt ? Date.parse(a.createdAt) : 0;\n const tb = b.createdAt ? Date.parse(b.createdAt) : 0;\n return tb - ta;\n}\n\n/**\n * The absolute path of a local backup file, or `null` for anything the download\n * route must not serve: a path outside the backup directory, a file that is not\n * a backup, or one that does not exist.\n */\nfunction resolveLocalBackupFile(dest: { path: string }, key: string): string | null {\n const stat = fs.existsSync(dest.path) ? fs.statSync(dest.path) : null;\n const dir = stat?.isDirectory() ? dest.path : path.dirname(dest.path);\n const resolvedDir = path.resolve(dir);\n const resolved = path.resolve(key);\n // Only allow reads inside the backup directory, and only backup files.\n if (!resolved.startsWith(resolvedDir + path.sep) || !isBackupFile(resolved)) {\n return null;\n }\n return fs.existsSync(resolved) ? resolved : null;\n}\n\n/** Read a single backup's bytes. Guards against path traversal for local. */\nexport async function readBackupBytes(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ bytes: Uint8Array<ArrayBuffer>; name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n if (!resolved) return null;\n return { bytes: new Uint8Array(fs.readFileSync(resolved)), name: path.basename(resolved) };\n }\n\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n const file = await storage.getObject(key, dest.bucket);\n if (!file) return null;\n return { bytes: new Uint8Array(await file.arrayBuffer()), name: key.split(\"/\").pop() || key };\n}\n\n/**\n * Whether `key` names a backup the download route would serve, without\n * opening it: the same guards as {@link openBackupStream}, and the file or\n * object is there. An object store without `statObject` is taken at its key —\n * reading the object to find out would be the download itself.\n */\nexport async function findBackup(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n return resolved ? { name: path.basename(resolved) } : null;\n }\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n if (storage.statObject && !(await storage.statObject(key, dest.bucket))) return null;\n return { name: key.split(\"/\").pop() || key };\n}\n\n/**\n * A single backup, opened for streaming, with its size. Same guards as\n * {@link readBackupBytes}.\n *\n * What the download route serves. A dump is the size of the database, and\n * reading it whole put all of it on the API process's heap — twice for a local\n * destination (`readFileSync`, then a copy) and on top of the controller's own\n * buffer for object storage — and a file over 2 GiB never downloaded at all:\n * `readFileSync` throws `ERR_FS_FILE_TOO_LARGE` there.\n *\n * A local file is read from disk as the response is written, and so is an\n * object through a controller with `getObjectStream` (the built-in S3 and GCS\n * ones). A custom controller without it still arrives through\n * {@link StorageController.getObject}, a buffered `File` by contract;\n * streaming that avoids the second copy.\n */\nexport async function openBackupStream(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ stream: ReadableStream<Uint8Array>; size: number; name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n if (!resolved) return null;\n const { size } = await fs.promises.stat(resolved);\n return { stream: fileStream(resolved), size, name: path.basename(resolved) };\n }\n\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n const name = key.split(\"/\").pop() || key;\n if (storage.getObjectStream) {\n const object = await storage.getObjectStream(key, { bucket: dest.bucket });\n return object ? { stream: object.stream, size: object.size, name } : null;\n }\n const file = await storage.getObject(key, dest.bucket);\n if (!file) return null;\n return { stream: file.stream(), size: file.size, name };\n}\n\n/** A file on disk as a web stream, read a chunk at a time as the response is written. */\nfunction fileStream(filePath: string): ReadableStream<Uint8Array> {\n const file = fs.createReadStream(filePath);\n const chunks: AsyncIterator<Buffer> = file[Symbol.asyncIterator]();\n return new ReadableStream<Uint8Array>({\n async pull(controller) {\n const next = await chunks.next();\n if (next.done) controller.close();\n else controller.enqueue(next.value);\n },\n cancel() {\n file.destroy();\n }\n });\n}\n","/**\n * The storage controller that reads a backup bucket.\n *\n * A backup destination is its own bucket, named by `BACKUP_DESTINATION`, and\n * the documented backup cron writes to it through a controller it builds from\n * the `S3_*` variables (GCS: application default credentials) — as\n * `rebase db backups list` does. The admin routes have to read it the same way.\n * The app's file storage is not that controller: it is configured for the\n * uploads bucket, it is a local directory in development, and in production\n * without an uploads bucket there is none at all.\n */\nimport { parseEnvBoolean } from \"@rebasepro/types\";\nimport { GCSStorageController } from \"../storage/GCSStorageController\";\nimport { S3StorageController } from \"../storage/S3StorageController\";\nimport type { StorageController } from \"../storage/types\";\nimport { parseBackupDestination, type BackupDestination } from \"./backup-common\";\n\n/** A destination in object storage — `s3://bucket/prefix` or `gs://bucket/prefix`. */\nexport type ObjectBackupDestination = Exclude<BackupDestination, { kind: \"local\" }>;\n\n/** The destination `BACKUP_DESTINATION` names, or `null` when it is unset. */\nexport function backupDestinationFromEnv(env: Record<string, string | undefined>): BackupDestination | null {\n const out = env.BACKUP_DESTINATION?.trim();\n return out ? parseBackupDestination(out) : null;\n}\n\n/** The destination as `BACKUP_DESTINATION` spells it. */\nexport function describeBackupDestination(dest: BackupDestination): string {\n if (dest.kind === \"local\") return dest.path;\n const scheme = dest.kind === \"s3\" ? \"s3\" : \"gs\";\n return `${scheme}://${dest.bucket}${dest.prefix ? `/${dest.prefix}` : \"\"}`;\n}\n\n/**\n * A controller for an object-storage destination's bucket, built from the\n * environment the way the backup cron and `rebase db backups list` build\n * theirs. Throws when an S3 destination has no credentials to read it with.\n */\nexport function backupStorageFromEnv(\n dest: ObjectBackupDestination,\n env: Record<string, string | undefined>\n): StorageController {\n if (dest.kind === \"gcs\") {\n return new GCSStorageController({ type: \"gcs\", bucket: dest.bucket });\n }\n // s3 also covers R2, MinIO and the other S3-compatible stores, through S3_ENDPOINT.\n if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {\n throw new Error(\n \"An S3 backup destination needs S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY in the environment \" +\n \"— the same S3_* variables the backup cron uses.\"\n );\n }\n return new S3StorageController({\n type: \"s3\",\n bucket: dest.bucket,\n region: env.S3_REGION || \"auto\",\n accessKeyId: env.S3_ACCESS_KEY_ID,\n secretAccessKey: env.S3_SECRET_ACCESS_KEY,\n endpoint: env.S3_ENDPOINT,\n // Unset stays unset, so the controller decides from the endpoint.\n forcePathStyle: parseEnvBoolean(env.S3_FORCE_PATH_STYLE)\n });\n}\n","import { Hono, type Context } from \"hono\";\nimport type { BackupDownloadLink, BackupListing, BackupScheduleStatus } from \"@rebasepro/types\";\nimport type { HonoEnv } from \"../api/types\";\nimport { ApiError, errorHandler } from \"../api/errors\";\nimport { jwtOf, type JwtService } from \"../auth/jwt\";\nimport type { StorageController } from \"../storage/types\";\nimport { BackupDestination, findBackup, listBackupObjects, openBackupStream } from \"./backup-common\";\nimport { backupStorageFromEnv, describeBackupDestination, type ObjectBackupDestination } from \"./backup-storage\";\n\nexport interface BackupRoutesConfig {\n /**\n * Resolve the current backup destination, or `null` when backups are not\n * configured (`BACKUP_DESTINATION` unset). Read lazily so a restart isn't\n * required to pick up config.\n */\n getDestination: () => BackupDestination | null;\n /**\n * The controller that reads an object-storage destination. It has to\n * address the destination's own bucket — never the app's file storage,\n * which is another bucket, a local directory, or nothing. Defaults to\n * {@link backupStorageFromEnv} over `process.env`, which is how the backup\n * cron builds the one it writes with.\n */\n storageFor?: (dest: ObjectBackupDestination) => StorageController;\n /**\n * The scheduled backup job and its last run — see `readBackupSchedule`.\n * Omitted where this process has no cron scheduler; the listing then\n * answers `schedule: null`.\n */\n getSchedule?: () => Promise<BackupScheduleStatus | null>;\n /**\n * True when this process does not run the scheduled jobs (the `api` role\n * of a split deployment). A local destination read here is then this\n * process's disk, not the one the scheduled backup writes to.\n */\n scheduledElsewhere?: boolean;\n}\n\n/** Why a destination could not be read, as the answer says it. */\nfunction unreadable(dest: BackupDestination, err: unknown): ApiError {\n const reason = err instanceof Error ? err.message : String(err);\n return ApiError.serviceUnavailable(`Cannot read the backups at ${describeBackupDestination(dest)}: ${reason}`);\n}\n\n// ─── Download links ──────────────────────────────────────────────────\n\n/**\n * The `purpose` of a download link's token. `verifyAccessToken` refuses\n * any token with a purpose, and the storage, MFA and MCP verifiers each demand\n * their own, so a link is never a session or any other credential, and none of\n * those is a link.\n */\nconst BACKUP_DOWNLOAD_PURPOSE = \"backup-download\";\n/** The token's audience: the one route that redeems it. */\nconst BACKUP_DOWNLOAD_AUDIENCE = \"rebase:admin/backups/download\";\n/** How long a link works. Long enough to start the download, not to pass around. */\nexport const BACKUP_DOWNLOAD_LINK_TTL_SECONDS = 60;\n\n/** What a link grants: one backup, at one destination. */\nfunction linkScope(dest: BackupDestination, key: string): string {\n return `backup:${describeBackupDestination(dest)}:${key}`;\n}\n\n/**\n * Whether `token` is a live link for `key` at `dest`, signed by `jwt` — the\n * backend answering. Never throws: anything else is a no.\n */\nasync function linkGrants(jwt: JwtService, token: string, dest: BackupDestination, key: string): Promise<boolean> {\n if (!jwt.isConfigured()) return false;\n const claims = await jwt.verifyPurposeToken<{ aud?: unknown; scope?: unknown }>(BACKUP_DOWNLOAD_PURPOSE, token)\n .catch(() => null);\n return claims?.aud === BACKUP_DOWNLOAD_AUDIENCE && claims.scope === linkScope(dest, key);\n}\n\n/** The configured destination, or the 400 every backup route answers without one. */\nfunction destinationOf(config: Pick<BackupRoutesConfig, \"getDestination\">): BackupDestination {\n const dest = config.getDestination();\n if (!dest) throw ApiError.badRequest(\"Backups are not configured (set BACKUP_DESTINATION).\");\n return dest;\n}\n\n/** {@link BackupRoutesConfig.storageFor}, with its default, for a destination that has a controller. */\nfunction storageResolver(config: Pick<BackupRoutesConfig, \"storageFor\">): (dest: BackupDestination) => StorageController | undefined {\n const storageFor = config.storageFor ?? ((dest: ObjectBackupDestination) => backupStorageFromEnv(dest, process.env));\n /** The controller for `dest`, or none for a local one. A destination it cannot read is an error, never `[]`. */\n return (dest) => {\n if (dest.kind === \"local\") return undefined;\n try {\n return storageFor(dest);\n } catch (err) {\n throw unreadable(dest, err);\n }\n };\n}\n\n/** One backup's bytes as the response, streamed: a dump is the size of the database. See `openBackupStream`. */\nasync function serveBackup(c: Context<HonoEnv>, dest: BackupDestination, key: string, storage: StorageController | undefined): Promise<Response> {\n const result = await openBackupStream(dest, key, storage);\n if (!result) {\n throw ApiError.notFound(`Backup not found: ${key}`);\n }\n c.header(\"Content-Type\", \"application/octet-stream\");\n c.header(\"Content-Length\", String(result.size));\n c.header(\"Content-Disposition\", `attachment; filename=\"${result.name}\"`);\n // A whole database, and on a link a URL that grants it: nothing in between keeps a copy.\n c.header(\"Cache-Control\", \"no-store\");\n return c.body(result.stream);\n}\n\n/**\n * `GET /download?key=…&token=…`: a download link spent in place of the bearer\n * header, for the one backup it names.\n *\n * Mount it at the backups prefix AHEAD of every gate on the admin prefix: the\n * auth adapter's own admin routers authenticate everything under `/admin`\n * before the Backups gate is reached. A request with no `token` passes\n * through untouched; one with a token is answered here — the backup, or a\n * 401 — and never falls through to the header: a bad link is a bad link.\n *\n * A browser cannot put a header on a navigation, so before links the SDK\n * fetched the dump and held it whole in the tab as a Blob. With a link the\n * browser streams it to disk.\n */\nexport function createBackupLinkRoutes(config: Pick<BackupRoutesConfig, \"getDestination\" | \"storageFor\">): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n router.onError(errorHandler);\n const storageOf = storageResolver(config);\n router.get(\"/download\", async (c, next) => {\n const token = c.req.query(\"token\");\n if (token === undefined) return next();\n const dest = destinationOf(config);\n const key = c.req.query(\"key\") ?? \"\";\n if (!(await linkGrants(jwtOf(c), token, dest, key))) {\n throw ApiError.unauthenticated(\n \"This download link is not valid for this backup, or it has expired. Links last \" +\n `${BACKUP_DOWNLOAD_LINK_TTL_SECONDS} seconds: ask for a new one.`\n );\n }\n return serveBackup(c, dest, key, storageOf(dest));\n });\n return router;\n}\n\n/**\n * Admin REST routes for the Backups panel.\n *\n * Routes (mounted under `/admin/backups`, admin-guarded by the caller):\n * GET / → list available backups, and the scheduled job's last run\n * GET /download → download a backup's bytes (?key=…)\n * POST /download-url → a 60-second link to one backup, `{ key }` → {@link BackupDownloadLink}\n *\n * The link is spent at `GET /download?key=…&token=…`, which\n * {@link createBackupLinkRoutes} answers ahead of the gate.\n */\nexport function createBackupRoutes(config: BackupRoutesConfig): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n router.onError(errorHandler);\n\n const storageOf = storageResolver(config);\n\n router.get(\"/\", async (c) => {\n // Read whether or not a destination is configured: a backup cron with\n // no BACKUP_DESTINATION fails every run, and that is exactly what the\n // panel has to be able to say.\n const schedule = config.getSchedule ? await config.getSchedule() : null;\n const dest = config.getDestination();\n if (!dest) {\n return c.json({ backups: [], destinationKind: \"local\", configured: false, schedule } satisfies BackupListing);\n }\n const backups = await listBackupObjects(dest, storageOf(dest)).catch((err: unknown) => {\n throw unreadable(dest, err);\n });\n // Only where there is a scheduled backup to write elsewhere.\n const localDiskOfThisProcess = dest.kind === \"local\" && config.scheduledElsewhere === true && schedule !== null;\n return c.json({\n backups,\n destinationKind: dest.kind,\n configured: true,\n schedule,\n ...(localDiskOfThisProcess ? { localDiskOfThisProcess } : {})\n } satisfies BackupListing);\n });\n\n router.get(\"/download\", async (c) => {\n const dest = destinationOf(config);\n const key = c.req.query(\"key\");\n if (!key) {\n throw ApiError.badRequest(\"Missing 'key' query parameter.\");\n }\n return serveBackup(c, dest, key, storageOf(dest));\n });\n\n router.post(\"/download-url\", async (c) => {\n const dest = destinationOf(config);\n const body: unknown = await c.req.json().catch(() => null);\n const key = typeof body === \"object\" && body !== null && \"key\" in body ? body.key : undefined;\n if (typeof key !== \"string\" || !key) {\n throw ApiError.badRequest(\"Send the backup's key: { \\\"key\\\": \\\"…\\\" }.\");\n }\n // The download route's own guards, so a link is never minted for a\n // key it would refuse; and the backup is there, so the browser is not\n // sent to a 404 in place of a file.\n if (!(await findBackup(dest, key, storageOf(dest)))) {\n throw ApiError.notFound(`Backup not found: ${key}`);\n }\n const jwt = jwtOf(c);\n if (!jwt.isConfigured()) {\n throw ApiError.serviceUnavailable(\n \"Download links are signed with the JWT secret, and this server has none (auth.jwtSecret). \" +\n \"Download through GET /download with the Authorization header instead.\"\n );\n }\n const token = await jwt.signPurposeToken(\n BACKUP_DOWNLOAD_PURPOSE,\n { aud: BACKUP_DOWNLOAD_AUDIENCE, scope: linkScope(dest, key) },\n BACKUP_DOWNLOAD_LINK_TTL_SECONDS\n );\n const download = new URL(\"download\", c.req.url).pathname;\n return c.json({\n url: `${download}?key=${encodeURIComponent(key)}&token=${encodeURIComponent(token)}`,\n token,\n expiresIn: BACKUP_DOWNLOAD_LINK_TTL_SECONDS\n } satisfies BackupDownloadLink);\n });\n\n return router;\n}\n","/**\n * The scheduled backup job, as the Backups panel reports it.\n *\n * A backup that fails every night has to be visible where the backups are\n * listed. Before this the panel read the destination and nothing else, so a\n * cron that could not run `pg_dump` left an empty list under \"wait for the next\n * scheduled run\" — while each failure sat in `cron_logs`, one panel away.\n */\nimport type {\n BackupRunOutcome,\n BackupScheduleStatus,\n CronJobDefinition,\n CronJobLogEntry,\n CronJobStatus,\n RejectedCronJob\n} from \"@rebasepro/types\";\nimport { isAlreadyExecutingSkip } from \"../cron/cron-scheduler\";\nimport { logger } from \"../utils/logger\";\n\n/**\n * The mark `createBackupCron` (in `@rebasepro/server-postgres`) puts on the\n * definition it returns.\n *\n * A registry symbol, so it is the same value across two installed copies of\n * either package, and neither package has to import the other: `server` must\n * not depend on `server-postgres`. The job cannot be recognised any other way —\n * its id is the cron file's name and its display name is configurable.\n */\nexport const BACKUP_CRON_MARK: unique symbol = Symbol.for(\"rebase.backupCron\");\n\n/** Whether a cron definition is the scheduled backup. */\nexport function isBackupCronDefinition(definition: CronJobDefinition): boolean {\n return Reflect.get(definition, BACKUP_CRON_MARK) === true;\n}\n\n/** The part of the cron scheduler this reads. */\nexport interface BackupScheduleSource {\n jobIdsWhere(predicate: (definition: CronJobDefinition) => boolean): string[];\n rejectedJobsWhere(predicate: (definition: CronJobDefinition) => boolean): RejectedCronJob[];\n fetchJob(id: string): Promise<CronJobStatus | undefined>;\n getJobLogsFromDb(id: string, limit?: number): Promise<CronJobLogEntry[]>;\n}\n\n/**\n * How many recent log entries to look through for the last scheduled run. A\n * skip — a slot that found the previous run still going — is logged as a\n * success with nothing done, and must not stand in for the run it skipped; nor\n * may a run someone started by hand, which is not the schedule's.\n */\nconst RECENT_RUNS = 50;\n\n/** A log entry as the panel reports a run. */\nfunction outcomeOf(entry: CronJobLogEntry): BackupRunOutcome {\n return {\n startedAt: entry.startedAt,\n finishedAt: entry.finishedAt,\n success: entry.success,\n ...(entry.success || !entry.error ? {} : { error: entry.error }),\n manual: entry.manual === true\n };\n}\n\n/**\n * The scheduled backup job and its last run, or `null` when the deployment\n * has none — registered or refused.\n */\nexport async function readBackupSchedule(source: BackupScheduleSource): Promise<BackupScheduleStatus | null> {\n const [jobId] = source.jobIdsWhere(isBackupCronDefinition);\n if (jobId === undefined) {\n // A refused job is not registered, and answered `null` here it read\n // as \"no backup cron\" under a hint to add the file the project has.\n const [refused] = source.rejectedJobsWhere(isBackupCronDefinition);\n return refused\n ? { jobId: refused.id, name: refused.name, schedule: refused.schedule, enabled: false, refused: refused.reason }\n : null;\n }\n\n const job = await source.fetchJob(jobId);\n if (!job) return null;\n // Off because its own definition says so — the documented cron file\n // exports `enabled: false` while BACKUP_SCHEDULE is unset — rather than\n // paused by someone. Resuming that in Cron Jobs would run the placeholder.\n const disabledInCode = !job.enabled &&\n source.jobIdsWhere(definition => isBackupCronDefinition(definition) && definition.enabled === false).includes(jobId);\n\n let recent: CronJobLogEntry[];\n let historyError: string | undefined;\n try {\n recent = await source.getJobLogsFromDb(jobId, RECENT_RUNS);\n } catch (err) {\n // Said, not read as \"has not run yet\". The listing itself still\n // answers: the backups at the destination are worth showing.\n logger.error(`[backups] Could not read the run history of the backup job \"${jobId}\"`, { error: err });\n recent = [];\n historyError = \"The backup job's run history could not be read. The server log has the reason.\";\n }\n const runs = recent.filter(entry => !isAlreadyExecutingSkip(entry));\n // \"Last scheduled backup\" is the schedule's: a later \"Run Now\" that worked\n // hid a failed nightly, and a failed test run read as a failed nightly.\n const lastScheduled = runs.find(entry => entry.manual !== true);\n // A run by hand since then is reported too, as what it is.\n const lastManual = runs.find(entry => entry.manual === true);\n const manualSince = lastManual && (!lastScheduled || lastManual.startedAt > lastScheduled.startedAt)\n ? lastManual\n : undefined;\n\n return {\n jobId,\n name: job.name,\n schedule: job.schedule,\n enabled: job.enabled,\n ...(disabledInCode ? { disabledInCode } : {}),\n ...(job.nextRunAt ? { nextRunAt: job.nextRunAt } : {}),\n ...(historyError ? { historyError } : {}),\n ...(lastScheduled ? { lastRun: outcomeOf(lastScheduled) } : {}),\n ...(manualSince ? { lastManualRun: outcomeOf(manualSince) } : {})\n };\n}\n","/**\n * Storage-generic backup helpers and admin routes for the Backups panel.\n * The `pg_dump`/`pg_restore` machinery lives in `@rebasepro/server-postgres`.\n */\nexport * from \"./backup-common\";\nexport { createBackupRoutes, createBackupLinkRoutes, BACKUP_DOWNLOAD_LINK_TTL_SECONDS } from \"./backup-routes\";\nexport { backupDestinationFromEnv, backupStorageFromEnv } from \"./backup-storage\";\nexport type { ObjectBackupDestination } from \"./backup-storage\";\nexport type { BackupRoutesConfig } from \"./backup-routes\";\nexport { readBackupSchedule, isBackupCronDefinition, BACKUP_CRON_MARK } from \"./backup-schedule\";\nexport type { BackupScheduleSource } from \"./backup-schedule\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,uBAAuB,KAAgC;CACnE,MAAM,KAAK,IAAI,MAAM,yBAAyB;CAC9C,IAAI,IAAI,OAAO;EAAE,MAAM;EAAM,QAAQ,GAAG;EAAI,QAAQ,GAAG,EAAE,CAAC,QAAQ,QAAQ,EAAE;CAAE;CAC9E,MAAM,MAAM,IAAI,MAAM,yBAAyB;CAC/C,IAAI,KAAK,OAAO;EAAE,MAAM;EAAO,QAAQ,IAAI;EAAI,QAAQ,IAAI,EAAE,CAAC,QAAQ,QAAQ,EAAE;CAAE;CAClF,OAAO;EAAE,MAAM;EAAS,MAAM;CAAI;AACtC;;;;;AAMA,SAAgB,qBAAqB,UAA+B;CAEhE,MAAM,SADO,SAAS,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,SAAA,CACvB,MAAM,0BAA0B;CACnD,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,GAAG,KAAK,OAAO;CACrB,MAAM,MACF,GAAG,IAAI,MAAM,GAAG,CAAC,EAAE,GAAG,IAAI,MAAM,GAAG,CAAC,EAAE,GAAG,IAAI,MAAM,GAAG,CAAC,EAAA,GACnD,IAAI,MAAM,GAAG,CAAC,EAAE,GAAG,IAAI,MAAM,GAAG,CAAC,EAAE,GAAG,IAAI,MAAM,GAAG,CAAC,EAAE;CAC9D,MAAM,OAAO,IAAI,KAAK,GAAG;CACzB,OAAO,OAAO,MAAM,KAAK,QAAQ,CAAC,IAAI,OAAO;AACjD;;;;;;;;;;;AAYA,IAAM,cAAc;AACpB,IAAM,iBAAiB;AAEvB,SAAS,cAAc,SAAyB;CAC5C,OAAO,QAAQ,MAAM,GAAG,EAAmB,IAAI;AACnD;;AAGA,SAAS,aAAa,KAAsB;CACxC,OAAO,IAAI,SAAS,WAAW,KAAK,IAAI,SAAS,cAAc;AACnE;;;;;AAMA,IAAM,iBAAiB;;;;;;;;;;AAWvB,eAAsB,kBAClB,SACA,QACA,QACiB;CACjB,MAAM,OAAiB,CAAC;CACxB,IAAI;CACJ,KAAK,IAAI,OAAO,GAAG,OAAO,gBAAgB,QAAQ;EAC9C,MAAM,SAAS,MAAM,QAAQ,YAAY,QAAQ;GAAE;GAAQ,YAAY;GAAM;EAAU,CAAC;EACxF,KAAK,MAAM,QAAQ,OAAO,OAAO,KAAK,KAAK,KAAK,QAAQ;EACxD,YAAY,OAAO;EACnB,IAAI,CAAC,WAAW,OAAO;CAC3B;CACA,OAAO,KACH,6BAA6B,OAAO,GAAG,OAAO,SAAS,eAAe,UAAU,KAAK,OAAO,2GAEhG;CACA,OAAO;AACX;;;;;;;;;;;;AAaA,SAAS,kBAAkB,MAA0B,KAAsB;CACvE,IAAI,CAAC,aAAa,GAAG,KAAK,2BAA2B,KAAK,GAAG,GAAG,OAAO;CAEvE,IADiB,IAAI,MAAM,GACvB,CAAA,CAAS,MAAM,YAAY,YAAY,MAAM,YAAY,OAAO,YAAY,IAAI,GAAG,OAAO;CAC9F,OAAO,KAAK,WAAW,MAAM,IAAI,WAAW,GAAG,KAAK,OAAO,EAAE;AACjE;;;;;;AAOA,eAAsB,kBAClB,MACA,SACqB;CACrB,IAAI,KAAK,SAAS,SAAS;EAKvB,MAAM,OAAO,MAAM,GAAG,SAAS,KAAK,KAAK,IAAI,CAAC,CAAC,YAAY,IAAI;EAC/D,IAAI,CAAC,MAAM,OAAO,CAAC;EACnB,MAAM,MAAM,KAAK,YAAY,IAAI,KAAK,OAAO,KAAK,QAAQ,KAAK,IAAI;EACnE,MAAM,QAAQ,MAAM,GAAG,SAAS,QAAQ,GAAG;EAC3C,MAAM,UAAU,IAAI,IAAI,KAAK;EAC7B,MAAM,QAAQ,MAAM,QAAQ,MAAM,EAAE,SAAS,WAAW,CAAC;EACzD,MAAM,QAAQ,MAAM,WAAW,OAAO,yBAAyB,MAAM,GAAG,SAAS,KAAK,KAAK,KAAK,KAAK,CAAC,CAAC,CAAC;EACxG,OAAO,MACF,KAAK,GAAG,MAAkB;GACvB,MAAM,OAAO,KAAK,KAAK,KAAK,CAAC;GAC7B,MAAM,KAAK,MAAM;GACjB,MAAM,YAAY,qBAAqB,CAAC,KAAK,GAAG;GAChD,MAAM,UAAU,cAAc,CAAC;GAC/B,OAAO;IACH,KAAK;IACL,MAAM;IACN,WAAW,GAAG;IACd,WAAW,UAAU,YAAY;IACjC,iBAAiB;IACjB,GAAI,QAAQ,IAAI,OAAO,IAAI,EAAE,YAAY,KAAK,KAAK,KAAK,OAAO,EAAE,IAAI,CAAC;GAC1E;EACJ,CAAC,CAAC,CACD,KAAK,QAAQ;CACtB;CAKA,IAAI,CAAC,SAAS,MAAM,IAAI,MAAM,gBAAgB,IAAI,CAAC;CACnD,MAAM,OAAO,MAAM,kBAAkB,SAAS,KAAK,SAAS,GAAG,KAAK,OAAO,KAAK,IAAI,KAAK,MAAM;CAC/F,MAAM,UAAU,IAAI,IAAI,IAAI;CAC5B,OAAO,KACF,QAAQ,QAAQ,IAAI,SAAS,WAAW,CAAC,CAAC,CAC1C,KAAK,QAAoB;EACtB,MAAM,YAAY,qBAAqB,GAAG;EAC1C,MAAM,UAAU,cAAc,GAAG;EACjC,OAAO;GACH;GACA,MAAM,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK;GAC9B,WAAW,YAAY,UAAU,YAAY,IAAI,KAAA;GACjD,iBAAiB,KAAK;GACtB,GAAI,QAAQ,IAAI,OAAO,IAAI,EAAE,YAAY,QAAQ,IAAI,CAAC;EAC1D;CACJ,CAAC,CAAC,CACD,KAAK,QAAQ;AACtB;;AAGA,IAAM,yBAAyB;;AAG/B,eAAe,WAAiB,OAAqB,OAAe,IAA2C;CAC3G,MAAM,UAAU,IAAI,MAAS,MAAM,MAAM;CACzC,IAAI,OAAO;CACX,MAAM,SAAS,YAAY;EACvB,OAAO,OAAO,MAAM,QAAQ;GACxB,MAAM,IAAI;GACV,QAAQ,KAAK,MAAM,GAAG,MAAM,EAAE;EAClC;CACJ;CACA,MAAM,QAAQ,IAAI,MAAM,KAAK,EAAE,QAAQ,KAAK,IAAI,OAAO,MAAM,MAAM,EAAE,GAAG,MAAM,CAAC;CAC/E,OAAO;AACX;AAEA,SAAS,gBAAgB,MAAsE;CAE3F,OAAO,6BADQ,KAAK,SAAS,OAAO,OAAO,KACA,KAAK,KAAK,SAAS,KAAK,SAAS,IAAI,KAAK,WAAW,GAAG;AACvG;AAEA,SAAS,SAAS,GAAe,GAAuB;CACpD,MAAM,KAAK,EAAE,YAAY,KAAK,MAAM,EAAE,SAAS,IAAI;CAEnD,QADW,EAAE,YAAY,KAAK,MAAM,EAAE,SAAS,IAAI,KACvC;AAChB;;;;;;AAOA,SAAS,uBAAuB,MAAwB,KAA4B;CAEhF,MAAM,OADO,GAAG,WAAW,KAAK,IAAI,IAAI,GAAG,SAAS,KAAK,IAAI,IAAI,KAAA,EAC/C,YAAY,IAAI,KAAK,OAAO,KAAK,QAAQ,KAAK,IAAI;CACpE,MAAM,cAAc,KAAK,QAAQ,GAAG;CACpC,MAAM,WAAW,KAAK,QAAQ,GAAG;CAEjC,IAAI,CAAC,SAAS,WAAW,cAAc,KAAK,GAAG,KAAK,CAAC,aAAa,QAAQ,GACtE,OAAO;CAEX,OAAO,GAAG,WAAW,QAAQ,IAAI,WAAW;AAChD;;AAGA,eAAsB,gBAClB,MACA,KACA,SACgE;CAChE,IAAI,KAAK,SAAS,SAAS;EACvB,MAAM,WAAW,uBAAuB,MAAM,GAAG;EACjD,IAAI,CAAC,UAAU,OAAO;EACtB,OAAO;GAAE,OAAO,IAAI,WAAW,GAAG,aAAa,QAAQ,CAAC;GAAG,MAAM,KAAK,SAAS,QAAQ;EAAE;CAC7F;CAEA,IAAI,CAAC,SAAS,MAAM,IAAI,MAAM,gBAAgB,IAAI,CAAC;CACnD,IAAI,CAAC,kBAAkB,MAAM,GAAG,GAAG,OAAO;CAC1C,MAAM,OAAO,MAAM,QAAQ,UAAU,KAAK,KAAK,MAAM;CACrD,IAAI,CAAC,MAAM,OAAO;CAClB,OAAO;EAAE,OAAO,IAAI,WAAW,MAAM,KAAK,YAAY,CAAC;EAAG,MAAM,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK;CAAI;AAChG;;;;;;;AAQA,eAAsB,WAClB,MACA,KACA,SACgC;CAChC,IAAI,KAAK,SAAS,SAAS;EACvB,MAAM,WAAW,uBAAuB,MAAM,GAAG;EACjD,OAAO,WAAW,EAAE,MAAM,KAAK,SAAS,QAAQ,EAAE,IAAI;CAC1D;CACA,IAAI,CAAC,SAAS,MAAM,IAAI,MAAM,gBAAgB,IAAI,CAAC;CACnD,IAAI,CAAC,kBAAkB,MAAM,GAAG,GAAG,OAAO;CAC1C,IAAI,QAAQ,cAAc,CAAE,MAAM,QAAQ,WAAW,KAAK,KAAK,MAAM,GAAI,OAAO;CAChF,OAAO,EAAE,MAAM,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,IAAI;AAC/C;;;;;;;;;;;;;;;;;AAkBA,eAAsB,iBAClB,MACA,KACA,SACkF;CAClF,IAAI,KAAK,SAAS,SAAS;EACvB,MAAM,WAAW,uBAAuB,MAAM,GAAG;EACjD,IAAI,CAAC,UAAU,OAAO;EACtB,MAAM,EAAE,SAAS,MAAM,GAAG,SAAS,KAAK,QAAQ;EAChD,OAAO;GAAE,QAAQ,WAAW,QAAQ;GAAG;GAAM,MAAM,KAAK,SAAS,QAAQ;EAAE;CAC/E;CAEA,IAAI,CAAC,SAAS,MAAM,IAAI,MAAM,gBAAgB,IAAI,CAAC;CACnD,IAAI,CAAC,kBAAkB,MAAM,GAAG,GAAG,OAAO;CAC1C,MAAM,OAAO,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK;CACrC,IAAI,QAAQ,iBAAiB;EACzB,MAAM,SAAS,MAAM,QAAQ,gBAAgB,KAAK,EAAE,QAAQ,KAAK,OAAO,CAAC;EACzE,OAAO,SAAS;GAAE,QAAQ,OAAO;GAAQ,MAAM,OAAO;GAAM;EAAK,IAAI;CACzE;CACA,MAAM,OAAO,MAAM,QAAQ,UAAU,KAAK,KAAK,MAAM;CACrD,IAAI,CAAC,MAAM,OAAO;CAClB,OAAO;EAAE,QAAQ,KAAK,OAAO;EAAG,MAAM,KAAK;EAAM;CAAK;AAC1D;;AAGA,SAAS,WAAW,UAA8C;CAC9D,MAAM,OAAO,GAAG,iBAAiB,QAAQ;CACzC,MAAM,SAAgC,KAAK,OAAO,cAAc,CAAC;CACjE,OAAO,IAAI,eAA2B;EAClC,MAAM,KAAK,YAAY;GACnB,MAAM,OAAO,MAAM,OAAO,KAAK;GAC/B,IAAI,KAAK,MAAM,WAAW,MAAM;QAC3B,WAAW,QAAQ,KAAK,KAAK;EACtC;EACA,SAAS;GACL,KAAK,QAAQ;EACjB;CACJ,CAAC;AACL;;;;;;;;;;;;;;;AC3SA,SAAgB,yBAAyB,KAAmE;CACxG,MAAM,MAAM,IAAI,oBAAoB,KAAK;CACzC,OAAO,MAAM,uBAAuB,GAAG,IAAI;AAC/C;;AAGA,SAAgB,0BAA0B,MAAiC;CACvE,IAAI,KAAK,SAAS,SAAS,OAAO,KAAK;CAEvC,OAAO,GADQ,KAAK,SAAS,OAAO,OAAO,KAC1B,KAAK,KAAK,SAAS,KAAK,SAAS,IAAI,KAAK,WAAW;AAC1E;;;;;;AAOA,SAAgB,qBACZ,MACA,KACiB;CACjB,IAAI,KAAK,SAAS,OACd,OAAO,IAAI,qBAAqB;EAAE,MAAM;EAAO,QAAQ,KAAK;CAAO,CAAC;CAGxE,IAAI,CAAC,IAAI,oBAAoB,CAAC,IAAI,sBAC9B,MAAM,IAAI,MACN,6IAEJ;CAEJ,OAAO,IAAI,oBAAoB;EAC3B,MAAM;EACN,QAAQ,KAAK;EACb,QAAQ,IAAI,aAAa;EACzB,aAAa,IAAI;EACjB,iBAAiB,IAAI;EACrB,UAAU,IAAI;EAEd,gBAAgB,gBAAgB,IAAI,mBAAmB;CAC3D,CAAC;AACL;;;;ACvBA,SAAS,WAAW,MAAyB,KAAwB;CACjE,MAAM,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;CAC9D,OAAO,SAAS,mBAAmB,8BAA8B,0BAA0B,IAAI,EAAE,IAAI,QAAQ;AACjH;;;;;;;AAUA,IAAM,0BAA0B;;AAEhC,IAAM,2BAA2B;;AAKjC,SAAS,UAAU,MAAyB,KAAqB;CAC7D,OAAO,UAAU,0BAA0B,IAAI,EAAE,GAAG;AACxD;;;;;AAMA,eAAe,WAAW,KAAiB,OAAe,MAAyB,KAA+B;CAC9G,IAAI,CAAC,IAAI,aAAa,GAAG,OAAO;CAChC,MAAM,SAAS,MAAM,IAAI,mBAAuD,yBAAyB,KAAK,CAAC,CAC1G,YAAY,IAAI;CACrB,OAAO,QAAQ,QAAQ,4BAA4B,OAAO,UAAU,UAAU,MAAM,GAAG;AAC3F;;AAGA,SAAS,cAAc,QAAuE;CAC1F,MAAM,OAAO,OAAO,eAAe;CACnC,IAAI,CAAC,MAAM,MAAM,SAAS,WAAW,sDAAsD;CAC3F,OAAO;AACX;;AAGA,SAAS,gBAAgB,QAA4G;CACjI,MAAM,aAAa,OAAO,gBAAgB,SAAkC,qBAAqB,MAAM,QAAQ,GAAG;;CAElH,QAAQ,SAAS;EACb,IAAI,KAAK,SAAS,SAAS,OAAO,KAAA;EAClC,IAAI;GACA,OAAO,WAAW,IAAI;EAC1B,SAAS,KAAK;GACV,MAAM,WAAW,MAAM,GAAG;EAC9B;CACJ;AACJ;;AAGA,eAAe,YAAY,GAAqB,MAAyB,KAAa,SAA2D;CAC7I,MAAM,SAAS,MAAM,iBAAiB,MAAM,KAAK,OAAO;CACxD,IAAI,CAAC,QACD,MAAM,SAAS,SAAS,qBAAqB,KAAK;CAEtD,EAAE,OAAO,gBAAgB,0BAA0B;CACnD,EAAE,OAAO,kBAAkB,OAAO,OAAO,IAAI,CAAC;CAC9C,EAAE,OAAO,uBAAuB,yBAAyB,OAAO,KAAK,EAAE;CAEvE,EAAE,OAAO,iBAAiB,UAAU;CACpC,OAAO,EAAE,KAAK,OAAO,MAAM;AAC/B;;;;;;;;;;;;;;;AAgBA,SAAgB,uBAAuB,QAAkF;CACrH,MAAM,SAAS,IAAI,KAAc;CACjC,OAAO,QAAQ,YAAY;CAC3B,MAAM,YAAY,gBAAgB,MAAM;CACxC,OAAO,IAAI,aAAa,OAAO,GAAG,SAAS;EACvC,MAAM,QAAQ,EAAE,IAAI,MAAM,OAAO;EACjC,IAAI,UAAU,KAAA,GAAW,OAAO,KAAK;EACrC,MAAM,OAAO,cAAc,MAAM;EACjC,MAAM,MAAM,EAAE,IAAI,MAAM,KAAK,KAAK;EAClC,IAAI,CAAE,MAAM,WAAW,MAAM,CAAC,GAAG,OAAO,MAAM,GAAG,GAC7C,MAAM,SAAS,gBACX,+GAEJ;EAEJ,OAAO,YAAY,GAAG,MAAM,KAAK,UAAU,IAAI,CAAC;CACpD,CAAC;CACD,OAAO;AACX;;;;;;;;;;;;AAaA,SAAgB,mBAAmB,QAA2C;CAC1E,MAAM,SAAS,IAAI,KAAc;CACjC,OAAO,QAAQ,YAAY;CAE3B,MAAM,YAAY,gBAAgB,MAAM;CAExC,OAAO,IAAI,KAAK,OAAO,MAAM;EAIzB,MAAM,WAAW,OAAO,cAAc,MAAM,OAAO,YAAY,IAAI;EACnE,MAAM,OAAO,OAAO,eAAe;EACnC,IAAI,CAAC,MACD,OAAO,EAAE,KAAK;GAAE,SAAS,CAAC;GAAG,iBAAiB;GAAS,YAAY;GAAO;EAAS,CAAyB;EAEhH,MAAM,UAAU,MAAM,kBAAkB,MAAM,UAAU,IAAI,CAAC,CAAC,CAAC,OAAO,QAAiB;GACnF,MAAM,WAAW,MAAM,GAAG;EAC9B,CAAC;EAED,MAAM,yBAAyB,KAAK,SAAS,WAAW,OAAO,uBAAuB,QAAQ,aAAa;EAC3G,OAAO,EAAE,KAAK;GACV;GACA,iBAAiB,KAAK;GACtB,YAAY;GACZ;GACA,GAAI,yBAAyB,EAAE,uBAAuB,IAAI,CAAC;EAC/D,CAAyB;CAC7B,CAAC;CAED,OAAO,IAAI,aAAa,OAAO,MAAM;EACjC,MAAM,OAAO,cAAc,MAAM;EACjC,MAAM,MAAM,EAAE,IAAI,MAAM,KAAK;EAC7B,IAAI,CAAC,KACD,MAAM,SAAS,WAAW,gCAAgC;EAE9D,OAAO,YAAY,GAAG,MAAM,KAAK,UAAU,IAAI,CAAC;CACpD,CAAC;CAED,OAAO,KAAK,iBAAiB,OAAO,MAAM;EACtC,MAAM,OAAO,cAAc,MAAM;EACjC,MAAM,OAAgB,MAAM,EAAE,IAAI,KAAK,CAAC,CAAC,YAAY,IAAI;EACzD,MAAM,MAAM,OAAO,SAAS,YAAY,SAAS,QAAQ,SAAS,OAAO,KAAK,MAAM,KAAA;EACpF,IAAI,OAAO,QAAQ,YAAY,CAAC,KAC5B,MAAM,SAAS,WAAW,4CAA4C;EAK1E,IAAI,CAAE,MAAM,WAAW,MAAM,KAAK,UAAU,IAAI,CAAC,GAC7C,MAAM,SAAS,SAAS,qBAAqB,KAAK;EAEtD,MAAM,MAAM,MAAM,CAAC;EACnB,IAAI,CAAC,IAAI,aAAa,GAClB,MAAM,SAAS,mBACX,iKAEJ;EAEJ,MAAM,QAAQ,MAAM,IAAI,iBACpB,yBACA;GAAE,KAAK;GAA0B,OAAO,UAAU,MAAM,GAAG;EAAE,GAAA,EAEjE;EACA,MAAM,WAAW,IAAI,IAAI,YAAY,EAAE,IAAI,GAAG,CAAC,CAAC;EAChD,OAAO,EAAE,KAAK;GACV,KAAK,GAAG,SAAS,OAAO,mBAAmB,GAAG,EAAE,SAAS,mBAAmB,KAAK;GACjF;GACA,WAAA;EACJ,CAA8B;CAClC,CAAC;CAED,OAAO;AACX;;;;;;;;;;;;ACtMA,IAAa,mBAAkC,OAAO,IAAI,mBAAmB;;AAG7E,SAAgB,uBAAuB,YAAwC;CAC3E,OAAO,QAAQ,IAAI,YAAY,gBAAgB,MAAM;AACzD;;;;;;;AAgBA,IAAM,cAAc;;AAGpB,SAAS,UAAU,OAA0C;CACzD,OAAO;EACH,WAAW,MAAM;EACjB,YAAY,MAAM;EAClB,SAAS,MAAM;EACf,GAAI,MAAM,WAAW,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,OAAO,MAAM,MAAM;EAC9D,QAAQ,MAAM,WAAW;CAC7B;AACJ;;;;;AAMA,eAAsB,mBAAmB,QAAoE;CACzG,MAAM,CAAC,SAAS,OAAO,YAAY,sBAAsB;CACzD,IAAI,UAAU,KAAA,GAAW;EAGrB,MAAM,CAAC,WAAW,OAAO,kBAAkB,sBAAsB;EACjE,OAAO,UACD;GAAE,OAAO,QAAQ;GAAI,MAAM,QAAQ;GAAM,UAAU,QAAQ;GAAU,SAAS;GAAO,SAAS,QAAQ;EAAO,IAC7G;CACV;CAEA,MAAM,MAAM,MAAM,OAAO,SAAS,KAAK;CACvC,IAAI,CAAC,KAAK,OAAO;CAIjB,MAAM,iBAAiB,CAAC,IAAI,WACxB,OAAO,aAAY,eAAc,uBAAuB,UAAU,KAAK,WAAW,YAAY,KAAK,CAAC,CAAC,SAAS,KAAK;CAEvH,IAAI;CACJ,IAAI;CACJ,IAAI;EACA,SAAS,MAAM,OAAO,iBAAiB,OAAO,WAAW;CAC7D,SAAS,KAAK;EAGV,OAAO,MAAM,+DAA+D,MAAM,IAAI,EAAE,OAAO,IAAI,CAAC;EACpG,SAAS,CAAC;EACV,eAAe;CACnB;CACA,MAAM,OAAO,OAAO,QAAO,UAAS,CAAC,uBAAuB,KAAK,CAAC;CAGlE,MAAM,gBAAgB,KAAK,MAAK,UAAS,MAAM,WAAW,IAAI;CAE9D,MAAM,aAAa,KAAK,MAAK,UAAS,MAAM,WAAW,IAAI;CAC3D,MAAM,cAAc,eAAe,CAAC,iBAAiB,WAAW,YAAY,cAAc,aACpF,aACA,KAAA;CAEN,OAAO;EACH;EACA,MAAM,IAAI;EACV,UAAU,IAAI;EACd,SAAS,IAAI;EACb,GAAI,iBAAiB,EAAE,eAAe,IAAI,CAAC;EAC3C,GAAI,IAAI,YAAY,EAAE,WAAW,IAAI,UAAU,IAAI,CAAC;EACpD,GAAI,eAAe,EAAE,aAAa,IAAI,CAAC;EACvC,GAAI,gBAAgB,EAAE,SAAS,UAAU,aAAa,EAAE,IAAI,CAAC;EAC7D,GAAI,cAAc,EAAE,eAAe,UAAU,WAAW,EAAE,IAAI,CAAC;CACnE;AACJ"}
|
|
1
|
+
{"version":3,"file":"backup-BVbHoKo8.js","names":[],"sources":["../src/backup/backup-common.ts","../src/backup/backup-storage.ts","../src/backup/backup-routes.ts","../src/backup/backup-schedule.ts","../src/backup/index.ts"],"sourcesContent":["/**\n * Storage-generic backup helpers used by the admin backup routes.\n *\n * These live in server (which owns {@link StorageController}) so the\n * admin API can list backups without depending on the Postgres driver —\n * `server` must not import from `server-postgres`. The `pg_dump`-side\n * copies of the tiny pure parsers live in `@rebasepro/server-postgres`;\n * keep the two in sync if the naming scheme ever changes.\n */\nimport fs from \"fs\";\nimport path from \"path\";\nimport type { BackupInfo, BackupDestinationKind } from \"@rebasepro/types\";\nimport type { StorageController } from \"../storage\";\nimport { logger } from \"../utils/logger\";\n\nexport type BackupDestination =\n | { kind: \"local\"; path: string }\n | { kind: \"s3\"; bucket: string; prefix: string }\n | { kind: \"gcs\"; bucket: string; prefix: string };\n\n/** Parse a destination string (`s3://…`, `gs://…`, or a local path). */\nexport function parseBackupDestination(out: string): BackupDestination {\n const s3 = out.match(/^s3:\\/\\/([^/]+)\\/?(.*)$/);\n if (s3) return { kind: \"s3\", bucket: s3[1], prefix: s3[2].replace(/\\/+$/, \"\") };\n const gcs = out.match(/^gs:\\/\\/([^/]+)\\/?(.*)$/);\n if (gcs) return { kind: \"gcs\", bucket: gcs[1], prefix: gcs[2].replace(/\\/+$/, \"\") };\n return { kind: \"local\", path: out };\n}\n\n/**\n * Recover the UTC creation time encoded in a `rebase-<db>-<ts>.dump` name.\n * Returns `null` for names that don't match, so foreign objects are ignored.\n */\nexport function parseBackupTimestamp(fileName: string): Date | null {\n const base = fileName.split(\"/\").pop() ?? fileName;\n const match = base.match(/-(\\d{8})T(\\d{6})Z\\.dump$/);\n if (!match) return null;\n const [, ymd, hms] = match;\n const iso =\n `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}` +\n `T${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}Z`;\n const date = new Date(iso);\n return Number.isNaN(date.getTime()) ? null : date;\n}\n\n/**\n * A backup is two files: the `.dump`, and beside it a `.globals.sql` holding\n * the cluster roles the dump's GRANTs and RLS policies name. Without that\n * sidecar a restore into a fresh cluster stops at the first GRANT to a role\n * that does not exist there. The CLI uploads, restores and prunes the two as\n * a pair. This listing used to know only the `.dump`, so a backup downloaded\n * from Studio arrived without its roles.\n *\n * The suffix mirrors `globalsFileForDump` in `@rebasepro/server-postgres`.\n */\nconst DUMP_SUFFIX = \".dump\";\nconst GLOBALS_SUFFIX = \".globals.sql\";\n\nfunction globalsKeyFor(dumpKey: string): string {\n return dumpKey.slice(0, -DUMP_SUFFIX.length) + GLOBALS_SUFFIX;\n}\n\n/** The only files the download route will serve. */\nfunction isBackupFile(key: string): boolean {\n return key.endsWith(DUMP_SUFFIX) || key.endsWith(GLOBALS_SUFFIX);\n}\n\n/**\n * How many pages of an object-storage listing to read before giving up — at\n * the providers' 1,000 keys a page, 100,000 objects, or 50,000 backups.\n */\nconst MAX_LIST_PAGES = 100;\n\n/**\n * Every key under a prefix, across pages.\n *\n * S3 and GCS answer a listing a page at a time, in ascending key order, and a\n * backup's key is `rebase-<db>-<UTC timestamp>`. Reading one page therefore\n * returned the OLDEST thousand objects: with retention unset and an hourly\n * schedule, the newest backup the panel and `rebase db backups list` showed\n * stopped advancing after about three weeks, which reads as \"backups stopped\".\n */\nexport async function listAllObjectKeys(\n storage: StorageController,\n prefix: string,\n bucket: string\n): Promise<string[]> {\n const keys: string[] = [];\n let pageToken: string | undefined;\n for (let page = 0; page < MAX_LIST_PAGES; page++) {\n const result = await storage.listObjects(prefix, { bucket, maxResults: 1000, pageToken });\n for (const item of result.items) keys.push(item.fullPath);\n pageToken = result.nextPageToken;\n if (!pageToken) return keys;\n }\n logger.warn(\n `[backups] Stopped listing ${bucket}/${prefix} after ${MAX_LIST_PAGES} pages (${keys.length} objects). ` +\n \"Set BACKUP_RETENTION_DAYS so old backups are pruned, or give the backups a prefix of their own.\"\n );\n return keys;\n}\n\n/**\n * Whether an object key is one the backup routes may serve: a backup file, at\n * the destination's prefix and nowhere else.\n *\n * The local branch has always been held to the backup directory; this branch\n * checked only the suffix, so any `.dump` in the bucket — `uploads/x.dump`, a\n * neighbouring `nightlyish/` prefix — was readable by naming it. A key is taken\n * as written, so a scheme (`s3://other-bucket/…`, which S3StorageController\n * would follow) or a `.`/`..`/empty segment (which a filesystem-backed or\n * S3-compatible store may resolve) is refused rather than interpreted.\n */\nfunction isObjectBackupKey(dest: { prefix: string }, key: string): boolean {\n if (!isBackupFile(key) || /^[a-z][a-z0-9+.-]*:\\/\\//i.test(key)) return false;\n const segments = key.split(\"/\");\n if (segments.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return false;\n return dest.prefix === \"\" || key.startsWith(`${dest.prefix}/`);\n}\n\n/**\n * List the backups at a destination as {@link BackupInfo}, newest first.\n * One entry per `.dump`, with its `.globals.sql` sidecar attached as\n * `globalsKey` when there is one.\n */\nexport async function listBackupObjects(\n dest: BackupDestination,\n storage?: StorageController\n): Promise<BackupInfo[]> {\n if (dest.kind === \"local\") {\n // Asynchronous throughout, a few stats at a time. This runs on the\n // Backups panel's request path, and a year of hourly backups is 8,760\n // dumps: stat'ed synchronously, the server answered nothing else\n // while it listed them.\n const stat = await fs.promises.stat(dest.path).catch(() => null);\n if (!stat) return [];\n const dir = stat.isDirectory() ? dest.path : path.dirname(dest.path);\n const files = await fs.promises.readdir(dir);\n const present = new Set(files);\n const dumps = files.filter((f) => f.endsWith(DUMP_SUFFIX));\n const stats = await mapBounded(dumps, LOCAL_STAT_CONCURRENCY, (f) => fs.promises.stat(path.join(dir, f)));\n return dumps\n .map((f, i): BackupInfo => {\n const full = path.join(dir, f);\n const st = stats[i];\n const createdAt = parseBackupTimestamp(f) ?? st.mtime;\n const globals = globalsKeyFor(f);\n return {\n key: full,\n name: f,\n sizeBytes: st.size,\n createdAt: createdAt.toISOString(),\n destinationKind: \"local\",\n ...(present.has(globals) ? { globalsKey: path.join(dir, globals) } : {})\n };\n })\n .sort(byNewest);\n }\n\n // An empty list is a claim about the bucket. Without a controller for it\n // nothing was read, and the panel said \"No backups yet.\" about a bucket\n // full of them.\n if (!storage) throw new Error(noControllerFor(dest));\n const keys = await listAllObjectKeys(storage, dest.prefix ? `${dest.prefix}/` : \"\", dest.bucket);\n const present = new Set(keys);\n return keys\n .filter((key) => key.endsWith(DUMP_SUFFIX))\n .map((key): BackupInfo => {\n const createdAt = parseBackupTimestamp(key);\n const globals = globalsKeyFor(key);\n return {\n key,\n name: key.split(\"/\").pop() || key,\n createdAt: createdAt ? createdAt.toISOString() : undefined,\n destinationKind: dest.kind as BackupDestinationKind,\n ...(present.has(globals) ? { globalsKey: globals } : {})\n };\n })\n .sort(byNewest);\n}\n\n/** Stats in flight at once while listing a local backup folder. */\nconst LOCAL_STAT_CONCURRENCY = 32;\n\n/** `fn` over `items`, at most `limit` at a time; results in input order. */\nasync function mapBounded<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {\n const results = new Array<R>(items.length);\n let next = 0;\n const worker = async () => {\n while (next < items.length) {\n const i = next++;\n results[i] = await fn(items[i]);\n }\n };\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));\n return results;\n}\n\nfunction noControllerFor(dest: { kind: \"s3\" | \"gcs\"; bucket: string; prefix: string }): string {\n const scheme = dest.kind === \"s3\" ? \"s3\" : \"gs\";\n return `No storage controller for ${scheme}://${dest.bucket}${dest.prefix ? `/${dest.prefix}` : \"\"}, so its backups cannot be read.`;\n}\n\nfunction byNewest(a: BackupInfo, b: BackupInfo): number {\n const ta = a.createdAt ? Date.parse(a.createdAt) : 0;\n const tb = b.createdAt ? Date.parse(b.createdAt) : 0;\n return tb - ta;\n}\n\n/**\n * The absolute path of a local backup file, or `null` for anything the download\n * route must not serve: a path outside the backup directory, a file that is not\n * a backup, or one that does not exist.\n */\nfunction resolveLocalBackupFile(dest: { path: string }, key: string): string | null {\n const stat = fs.existsSync(dest.path) ? fs.statSync(dest.path) : null;\n const dir = stat?.isDirectory() ? dest.path : path.dirname(dest.path);\n const resolvedDir = path.resolve(dir);\n const resolved = path.resolve(key);\n // Only allow reads inside the backup directory, and only backup files.\n if (!resolved.startsWith(resolvedDir + path.sep) || !isBackupFile(resolved)) {\n return null;\n }\n return fs.existsSync(resolved) ? resolved : null;\n}\n\n/** Read a single backup's bytes. Guards against path traversal for local. */\nexport async function readBackupBytes(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ bytes: Uint8Array<ArrayBuffer>; name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n if (!resolved) return null;\n return { bytes: new Uint8Array(fs.readFileSync(resolved)), name: path.basename(resolved) };\n }\n\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n const file = await storage.getObject(key, dest.bucket);\n if (!file) return null;\n return { bytes: new Uint8Array(await file.arrayBuffer()), name: key.split(\"/\").pop() || key };\n}\n\n/**\n * Whether `key` names a backup the download route would serve, without\n * opening it: the same guards as {@link openBackupStream}, and the file or\n * object is there. An object store without `statObject` is taken at its key —\n * reading the object to find out would be the download itself.\n */\nexport async function findBackup(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n return resolved ? { name: path.basename(resolved) } : null;\n }\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n if (storage.statObject && !(await storage.statObject(key, dest.bucket))) return null;\n return { name: key.split(\"/\").pop() || key };\n}\n\n/**\n * A single backup, opened for streaming, with its size. Same guards as\n * {@link readBackupBytes}.\n *\n * What the download route serves. A dump is the size of the database, and\n * reading it whole put all of it on the API process's heap — twice for a local\n * destination (`readFileSync`, then a copy) and on top of the controller's own\n * buffer for object storage — and a file over 2 GiB never downloaded at all:\n * `readFileSync` throws `ERR_FS_FILE_TOO_LARGE` there.\n *\n * A local file is read from disk as the response is written, and so is an\n * object through a controller with `getObjectStream` (the built-in S3 and GCS\n * ones). A custom controller without it still arrives through\n * {@link StorageController.getObject}, a buffered `File` by contract;\n * streaming that avoids the second copy.\n */\nexport async function openBackupStream(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ stream: ReadableStream<Uint8Array>; size: number; name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n if (!resolved) return null;\n const { size } = await fs.promises.stat(resolved);\n return { stream: fileStream(resolved), size, name: path.basename(resolved) };\n }\n\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n const name = key.split(\"/\").pop() || key;\n if (storage.getObjectStream) {\n const object = await storage.getObjectStream(key, { bucket: dest.bucket });\n return object ? { stream: object.stream, size: object.size, name } : null;\n }\n const file = await storage.getObject(key, dest.bucket);\n if (!file) return null;\n return { stream: file.stream(), size: file.size, name };\n}\n\n/** A file on disk as a web stream, read a chunk at a time as the response is written. */\nfunction fileStream(filePath: string): ReadableStream<Uint8Array> {\n const file = fs.createReadStream(filePath);\n const chunks: AsyncIterator<Buffer> = file[Symbol.asyncIterator]();\n return new ReadableStream<Uint8Array>({\n async pull(controller) {\n const next = await chunks.next();\n if (next.done) controller.close();\n else controller.enqueue(next.value);\n },\n cancel() {\n file.destroy();\n }\n });\n}\n","/**\n * The storage controller that reads a backup bucket.\n *\n * A backup destination is its own bucket, named by `BACKUP_DESTINATION`, and\n * the documented backup cron writes to it through a controller it builds from\n * the `S3_*` variables (GCS: application default credentials) — as\n * `rebase db backups list` does. The admin routes have to read it the same way.\n * The app's file storage is not that controller: it is configured for the\n * uploads bucket, it is a local directory in development, and in production\n * without an uploads bucket there is none at all.\n */\nimport { parseEnvBoolean } from \"@rebasepro/types\";\nimport { GCSStorageController } from \"../storage/GCSStorageController\";\nimport { S3StorageController } from \"../storage/S3StorageController\";\nimport type { StorageController } from \"../storage/types\";\nimport { parseBackupDestination, type BackupDestination } from \"./backup-common\";\n\n/** A destination in object storage — `s3://bucket/prefix` or `gs://bucket/prefix`. */\nexport type ObjectBackupDestination = Exclude<BackupDestination, { kind: \"local\" }>;\n\n/** The destination `BACKUP_DESTINATION` names, or `null` when it is unset. */\nexport function backupDestinationFromEnv(env: Record<string, string | undefined>): BackupDestination | null {\n const out = env.BACKUP_DESTINATION?.trim();\n return out ? parseBackupDestination(out) : null;\n}\n\n/** The destination as `BACKUP_DESTINATION` spells it. */\nexport function describeBackupDestination(dest: BackupDestination): string {\n if (dest.kind === \"local\") return dest.path;\n const scheme = dest.kind === \"s3\" ? \"s3\" : \"gs\";\n return `${scheme}://${dest.bucket}${dest.prefix ? `/${dest.prefix}` : \"\"}`;\n}\n\n/**\n * A controller for an object-storage destination's bucket, built from the\n * environment the way the backup cron and `rebase db backups list` build\n * theirs. Throws when an S3 destination has no credentials to read it with.\n */\nexport function backupStorageFromEnv(\n dest: ObjectBackupDestination,\n env: Record<string, string | undefined>\n): StorageController {\n if (dest.kind === \"gcs\") {\n return new GCSStorageController({ type: \"gcs\", bucket: dest.bucket });\n }\n // s3 also covers R2, MinIO and the other S3-compatible stores, through S3_ENDPOINT.\n if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {\n throw new Error(\n \"An S3 backup destination needs S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY in the environment \" +\n \"— the same S3_* variables the backup cron uses.\"\n );\n }\n return new S3StorageController({\n type: \"s3\",\n bucket: dest.bucket,\n region: env.S3_REGION || \"auto\",\n accessKeyId: env.S3_ACCESS_KEY_ID,\n secretAccessKey: env.S3_SECRET_ACCESS_KEY,\n endpoint: env.S3_ENDPOINT,\n // Unset stays unset, so the controller decides from the endpoint.\n forcePathStyle: parseEnvBoolean(env.S3_FORCE_PATH_STYLE)\n });\n}\n","import { Hono, type Context } from \"hono\";\nimport type { BackupDownloadLink, BackupListing, BackupScheduleStatus } from \"@rebasepro/types\";\nimport type { HonoEnv } from \"../api/types\";\nimport { ApiError, errorHandler } from \"../api/errors\";\nimport { jwtOf, type JwtService } from \"../auth/jwt\";\nimport type { StorageController } from \"../storage/types\";\nimport { BackupDestination, findBackup, listBackupObjects, openBackupStream } from \"./backup-common\";\nimport { backupStorageFromEnv, describeBackupDestination, type ObjectBackupDestination } from \"./backup-storage\";\n\nexport interface BackupRoutesConfig {\n /**\n * Resolve the current backup destination, or `null` when backups are not\n * configured (`BACKUP_DESTINATION` unset). Read lazily so a restart isn't\n * required to pick up config.\n */\n getDestination: () => BackupDestination | null;\n /**\n * The controller that reads an object-storage destination. It has to\n * address the destination's own bucket — never the app's file storage,\n * which is another bucket, a local directory, or nothing. Defaults to\n * {@link backupStorageFromEnv} over `process.env`, which is how the backup\n * cron builds the one it writes with.\n */\n storageFor?: (dest: ObjectBackupDestination) => StorageController;\n /**\n * The scheduled backup job and its last run — see `readBackupSchedule`.\n * Omitted where this process has no cron scheduler; the listing then\n * answers `schedule: null`.\n */\n getSchedule?: () => Promise<BackupScheduleStatus | null>;\n /**\n * True when this process does not run the scheduled jobs (the `api` role\n * of a split deployment). A local destination read here is then this\n * process's disk, not the one the scheduled backup writes to.\n */\n scheduledElsewhere?: boolean;\n}\n\n/** Why a destination could not be read, as the answer says it. */\nfunction unreadable(dest: BackupDestination, err: unknown): ApiError {\n const reason = err instanceof Error ? err.message : String(err);\n return ApiError.serviceUnavailable(`Cannot read the backups at ${describeBackupDestination(dest)}: ${reason}`);\n}\n\n// ─── Download links ──────────────────────────────────────────────────\n\n/**\n * The `purpose` of a download link's token. `verifyAccessToken` refuses\n * any token with a purpose, and the storage, MFA and MCP verifiers each demand\n * their own, so a link is never a session or any other credential, and none of\n * those is a link.\n */\nconst BACKUP_DOWNLOAD_PURPOSE = \"backup-download\";\n/** The token's audience: the one route that redeems it. */\nconst BACKUP_DOWNLOAD_AUDIENCE = \"rebase:admin/backups/download\";\n/** How long a link works. Long enough to start the download, not to pass around. */\nexport const BACKUP_DOWNLOAD_LINK_TTL_SECONDS = 60;\n\n/** What a link grants: one backup, at one destination. */\nfunction linkScope(dest: BackupDestination, key: string): string {\n return `backup:${describeBackupDestination(dest)}:${key}`;\n}\n\n/**\n * Whether `token` is a live link for `key` at `dest`, signed by `jwt` — the\n * backend answering. Never throws: anything else is a no.\n */\nasync function linkGrants(jwt: JwtService, token: string, dest: BackupDestination, key: string): Promise<boolean> {\n if (!jwt.isConfigured()) return false;\n const claims = await jwt.verifyPurposeToken<{ aud?: unknown; scope?: unknown }>(BACKUP_DOWNLOAD_PURPOSE, token)\n .catch(() => null);\n return claims?.aud === BACKUP_DOWNLOAD_AUDIENCE && claims.scope === linkScope(dest, key);\n}\n\n/** The configured destination, or the 400 every backup route answers without one. */\nfunction destinationOf(config: Pick<BackupRoutesConfig, \"getDestination\">): BackupDestination {\n const dest = config.getDestination();\n if (!dest) throw ApiError.badRequest(\"Backups are not configured (set BACKUP_DESTINATION).\");\n return dest;\n}\n\n/** {@link BackupRoutesConfig.storageFor}, with its default, for a destination that has a controller. */\nfunction storageResolver(config: Pick<BackupRoutesConfig, \"storageFor\">): (dest: BackupDestination) => StorageController | undefined {\n const storageFor = config.storageFor ?? ((dest: ObjectBackupDestination) => backupStorageFromEnv(dest, process.env));\n /** The controller for `dest`, or none for a local one. A destination it cannot read is an error, never `[]`. */\n return (dest) => {\n if (dest.kind === \"local\") return undefined;\n try {\n return storageFor(dest);\n } catch (err) {\n throw unreadable(dest, err);\n }\n };\n}\n\n/** One backup's bytes as the response, streamed: a dump is the size of the database. See `openBackupStream`. */\nasync function serveBackup(c: Context<HonoEnv>, dest: BackupDestination, key: string, storage: StorageController | undefined): Promise<Response> {\n const result = await openBackupStream(dest, key, storage);\n if (!result) {\n throw ApiError.notFound(`Backup not found: ${key}`);\n }\n c.header(\"Content-Type\", \"application/octet-stream\");\n c.header(\"Content-Length\", String(result.size));\n c.header(\"Content-Disposition\", `attachment; filename=\"${result.name}\"`);\n // A whole database, and on a link a URL that grants it: nothing in between keeps a copy.\n c.header(\"Cache-Control\", \"no-store\");\n return c.body(result.stream);\n}\n\n/**\n * `GET /download?key=…&token=…`: a download link spent in place of the bearer\n * header, for the one backup it names.\n *\n * Mount it at the backups prefix AHEAD of every gate on the admin prefix: the\n * auth adapter's own admin routers authenticate everything under `/admin`\n * before the Backups gate is reached. A request with no `token` passes\n * through untouched; one with a token is answered here — the backup, or a\n * 401 — and never falls through to the header: a bad link is a bad link.\n *\n * A browser cannot put a header on a navigation, so before links the SDK\n * fetched the dump and held it whole in the tab as a Blob. With a link the\n * browser streams it to disk.\n */\nexport function createBackupLinkRoutes(config: Pick<BackupRoutesConfig, \"getDestination\" | \"storageFor\">): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n router.onError(errorHandler);\n const storageOf = storageResolver(config);\n router.get(\"/download\", async (c, next) => {\n const token = c.req.query(\"token\");\n if (token === undefined) return next();\n const dest = destinationOf(config);\n const key = c.req.query(\"key\") ?? \"\";\n if (!(await linkGrants(jwtOf(c), token, dest, key))) {\n throw ApiError.unauthenticated(\n \"This download link is not valid for this backup, or it has expired. Links last \" +\n `${BACKUP_DOWNLOAD_LINK_TTL_SECONDS} seconds: ask for a new one.`\n );\n }\n return serveBackup(c, dest, key, storageOf(dest));\n });\n return router;\n}\n\n/**\n * Admin REST routes for the Backups panel.\n *\n * Routes (mounted under `/admin/backups`, admin-guarded by the caller):\n * GET / → list available backups, and the scheduled job's last run\n * GET /download → download a backup's bytes (?key=…)\n * POST /download-url → a 60-second link to one backup, `{ key }` → {@link BackupDownloadLink}\n *\n * The link is spent at `GET /download?key=…&token=…`, which\n * {@link createBackupLinkRoutes} answers ahead of the gate.\n */\nexport function createBackupRoutes(config: BackupRoutesConfig): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n router.onError(errorHandler);\n\n const storageOf = storageResolver(config);\n\n router.get(\"/\", async (c) => {\n // Read whether or not a destination is configured: a backup cron with\n // no BACKUP_DESTINATION fails every run, and that is exactly what the\n // panel has to be able to say.\n const schedule = config.getSchedule ? await config.getSchedule() : null;\n const dest = config.getDestination();\n if (!dest) {\n return c.json({ backups: [], destinationKind: \"local\", configured: false, schedule } satisfies BackupListing);\n }\n const backups = await listBackupObjects(dest, storageOf(dest)).catch((err: unknown) => {\n throw unreadable(dest, err);\n });\n // Only where there is a scheduled backup to write elsewhere.\n const localDiskOfThisProcess = dest.kind === \"local\" && config.scheduledElsewhere === true && schedule !== null;\n return c.json({\n backups,\n destinationKind: dest.kind,\n configured: true,\n schedule,\n ...(localDiskOfThisProcess ? { localDiskOfThisProcess } : {})\n } satisfies BackupListing);\n });\n\n router.get(\"/download\", async (c) => {\n const dest = destinationOf(config);\n const key = c.req.query(\"key\");\n if (!key) {\n throw ApiError.badRequest(\"Missing 'key' query parameter.\");\n }\n return serveBackup(c, dest, key, storageOf(dest));\n });\n\n router.post(\"/download-url\", async (c) => {\n const dest = destinationOf(config);\n const body: unknown = await c.req.json().catch(() => null);\n const key = typeof body === \"object\" && body !== null && \"key\" in body ? body.key : undefined;\n if (typeof key !== \"string\" || !key) {\n throw ApiError.badRequest(\"Send the backup's key: { \\\"key\\\": \\\"…\\\" }.\");\n }\n // The download route's own guards, so a link is never minted for a\n // key it would refuse; and the backup is there, so the browser is not\n // sent to a 404 in place of a file.\n if (!(await findBackup(dest, key, storageOf(dest)))) {\n throw ApiError.notFound(`Backup not found: ${key}`);\n }\n const jwt = jwtOf(c);\n if (!jwt.isConfigured()) {\n throw ApiError.serviceUnavailable(\n \"Download links are signed with the JWT secret, and this server has none (auth.jwtSecret). \" +\n \"Download through GET /download with the Authorization header instead.\"\n );\n }\n const token = await jwt.signPurposeToken(\n BACKUP_DOWNLOAD_PURPOSE,\n { aud: BACKUP_DOWNLOAD_AUDIENCE, scope: linkScope(dest, key) },\n BACKUP_DOWNLOAD_LINK_TTL_SECONDS\n );\n const download = new URL(\"download\", c.req.url).pathname;\n return c.json({\n url: `${download}?key=${encodeURIComponent(key)}&token=${encodeURIComponent(token)}`,\n token,\n expiresIn: BACKUP_DOWNLOAD_LINK_TTL_SECONDS\n } satisfies BackupDownloadLink);\n });\n\n return router;\n}\n","/**\n * The scheduled backup job, as the Backups panel reports it.\n *\n * A backup that fails every night has to be visible where the backups are\n * listed. Before this the panel read the destination and nothing else, so a\n * cron that could not run `pg_dump` left an empty list under \"wait for the next\n * scheduled run\" — while each failure sat in `cron_logs`, one panel away.\n */\nimport type {\n BackupRunOutcome,\n BackupScheduleStatus,\n CronJobDefinition,\n CronJobLogEntry,\n CronJobStatus,\n RejectedCronJob\n} from \"@rebasepro/types\";\nimport { isAlreadyExecutingSkip } from \"../cron/cron-scheduler\";\nimport { logger } from \"../utils/logger\";\n\n/**\n * The mark `createBackupCron` (in `@rebasepro/server-postgres`) puts on the\n * definition it returns.\n *\n * A registry symbol, so it is the same value across two installed copies of\n * either package, and neither package has to import the other: `server` must\n * not depend on `server-postgres`. The job cannot be recognised any other way —\n * its id is the cron file's name and its display name is configurable.\n */\nexport const BACKUP_CRON_MARK: unique symbol = Symbol.for(\"rebase.backupCron\");\n\n/** Whether a cron definition is the scheduled backup. */\nexport function isBackupCronDefinition(definition: CronJobDefinition): boolean {\n return Reflect.get(definition, BACKUP_CRON_MARK) === true;\n}\n\n/** The part of the cron scheduler this reads. */\nexport interface BackupScheduleSource {\n jobIdsWhere(predicate: (definition: CronJobDefinition) => boolean): string[];\n rejectedJobsWhere(predicate: (definition: CronJobDefinition) => boolean): RejectedCronJob[];\n fetchJob(id: string): Promise<CronJobStatus | undefined>;\n getJobLogsFromDb(id: string, limit?: number): Promise<CronJobLogEntry[]>;\n}\n\n/**\n * How many recent log entries to look through for the last scheduled run. A\n * skip — a slot that found the previous run still going — is logged as a\n * success with nothing done, and must not stand in for the run it skipped; nor\n * may a run someone started by hand, which is not the schedule's.\n */\nconst RECENT_RUNS = 50;\n\n/** A log entry as the panel reports a run. */\nfunction outcomeOf(entry: CronJobLogEntry): BackupRunOutcome {\n return {\n startedAt: entry.startedAt,\n finishedAt: entry.finishedAt,\n success: entry.success,\n ...(entry.success || !entry.error ? {} : { error: entry.error }),\n manual: entry.manual === true\n };\n}\n\n/**\n * The scheduled backup job and its last run, or `null` when the deployment\n * has none — registered or refused.\n */\nexport async function readBackupSchedule(source: BackupScheduleSource): Promise<BackupScheduleStatus | null> {\n const [jobId] = source.jobIdsWhere(isBackupCronDefinition);\n if (jobId === undefined) {\n // A refused job is not registered, and answered `null` here it read\n // as \"no backup cron\" under a hint to add the file the project has.\n const [refused] = source.rejectedJobsWhere(isBackupCronDefinition);\n return refused\n ? { jobId: refused.id, name: refused.name, schedule: refused.schedule, enabled: false, refused: refused.reason }\n : null;\n }\n\n const job = await source.fetchJob(jobId);\n if (!job) return null;\n // Off because its own definition says so — the documented cron file\n // exports `enabled: false` while BACKUP_SCHEDULE is unset — rather than\n // paused by someone. Resuming that in Cron Jobs would run the placeholder.\n const disabledInCode = !job.enabled &&\n source.jobIdsWhere(definition => isBackupCronDefinition(definition) && definition.enabled === false).includes(jobId);\n\n let recent: CronJobLogEntry[];\n let historyError: string | undefined;\n try {\n recent = await source.getJobLogsFromDb(jobId, RECENT_RUNS);\n } catch (err) {\n // Said, not read as \"has not run yet\". The listing itself still\n // answers: the backups at the destination are worth showing.\n logger.error(`[backups] Could not read the run history of the backup job \"${jobId}\"`, { error: err });\n recent = [];\n historyError = \"The backup job's run history could not be read. The server log has the reason.\";\n }\n const runs = recent.filter(entry => !isAlreadyExecutingSkip(entry));\n // \"Last scheduled backup\" is the schedule's: a later \"Run Now\" that worked\n // hid a failed nightly, and a failed test run read as a failed nightly.\n const lastScheduled = runs.find(entry => entry.manual !== true);\n // A run by hand since then is reported too, as what it is.\n const lastManual = runs.find(entry => entry.manual === true);\n const manualSince = lastManual && (!lastScheduled || lastManual.startedAt > lastScheduled.startedAt)\n ? lastManual\n : undefined;\n\n return {\n jobId,\n name: job.name,\n schedule: job.schedule,\n enabled: job.enabled,\n ...(disabledInCode ? { disabledInCode } : {}),\n ...(job.nextRunAt ? { nextRunAt: job.nextRunAt } : {}),\n ...(historyError ? { historyError } : {}),\n ...(lastScheduled ? { lastRun: outcomeOf(lastScheduled) } : {}),\n ...(manualSince ? { lastManualRun: outcomeOf(manualSince) } : {})\n };\n}\n","/**\n * Storage-generic backup helpers and admin routes for the Backups panel.\n * The `pg_dump`/`pg_restore` machinery lives in `@rebasepro/server-postgres`.\n */\nexport * from \"./backup-common\";\nexport { createBackupRoutes, createBackupLinkRoutes, BACKUP_DOWNLOAD_LINK_TTL_SECONDS } from \"./backup-routes\";\nexport { backupDestinationFromEnv, backupStorageFromEnv } from \"./backup-storage\";\nexport type { ObjectBackupDestination } from \"./backup-storage\";\nexport type { BackupRoutesConfig } from \"./backup-routes\";\nexport { readBackupSchedule, isBackupCronDefinition, BACKUP_CRON_MARK } from \"./backup-schedule\";\nexport type { BackupScheduleSource } from \"./backup-schedule\";\n"],"mappings":"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"}
|
package/dist/boot/boot.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { RebaseBackendInstance } from "../init.js";
|
|
|
5
5
|
import type { HonoEnv } from "../api/types.js";
|
|
6
6
|
import { type Logger } from "../utils/logger.js";
|
|
7
7
|
import { type RebaseBootEnv } from "./env.js";
|
|
8
|
+
import { resolveProvisionOnly } from "./provision-only.js";
|
|
9
|
+
export { resolveProvisionOnly };
|
|
8
10
|
import { type LoadedBundle } from "./bundle.js";
|
|
9
11
|
import { resolveStorageSources, type EnvBag } from "./sources.js";
|
|
10
12
|
import { type InitializedDataSource } from "./driver.js";
|
|
@@ -98,21 +100,6 @@ export declare function bootFromBundle(options?: BootOptions): Promise<BootedRun
|
|
|
98
100
|
* answer is "set DATABASE_URL". Anything else keeps its stack, because it is a
|
|
99
101
|
* bug and the trace is the point.
|
|
100
102
|
*/
|
|
101
|
-
/**
|
|
102
|
-
* Whether this process should provision the schema and stop, rather than serve.
|
|
103
|
-
*
|
|
104
|
-
* The shape a migration Job wants: same image, same bundle, same boot — and no
|
|
105
|
-
* socket. Nothing else changes, which is the point. A separate migration image
|
|
106
|
-
* would be a second artifact to build, publish and keep in step with the
|
|
107
|
-
* runtime, and the failure mode of it drifting is a schema applied by a version
|
|
108
|
-
* that is not the one about to run against it.
|
|
109
|
-
*
|
|
110
|
-
* A blank value is *unset*: `REBASE_PROVISION_ONLY=${SOMETHING}` with SOMETHING
|
|
111
|
-
* undefined is the ordinary way to write a compose file or a Helm template, and
|
|
112
|
-
* reading it as true would turn an ordinary deployment into one that migrates
|
|
113
|
-
* and then refuses to serve.
|
|
114
|
-
*/
|
|
115
|
-
export declare function resolveProvisionOnly(env?: NodeJS.ProcessEnv): boolean;
|
|
116
103
|
export declare function runFromBundle(options?: BootOptions): Promise<BootedRuntime>;
|
|
117
104
|
/** The two logger methods {@link reportBootFailure} writes through. */
|
|
118
105
|
export type BootFailureLog = Pick<Logger, "error" | "debug">;
|
|
@@ -25,6 +25,40 @@
|
|
|
25
25
|
export type SqlExec = (sqlText: string, options?: {
|
|
26
26
|
params?: unknown[];
|
|
27
27
|
}) => Promise<Record<string, unknown>[]>;
|
|
28
|
+
/** A statement with any leading whitespace and comments removed. */
|
|
29
|
+
export declare function withoutLeadingComments(sqlText: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Whether a statement changes the database's structure or its privileges.
|
|
32
|
+
*
|
|
33
|
+
* Decided on the leading keyword, which is exact for every statement the
|
|
34
|
+
* framework writes: none of them hides DDL behind a `WITH` or a function call.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isSchemaChange(sqlText: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Raised in place of a schema change on a backend that provisions nothing.
|
|
39
|
+
*
|
|
40
|
+
* Its own class so a bootstrap step can tell "this was not run on purpose" from
|
|
41
|
+
* "this failed", and say the first at `debug` rather than as an error per
|
|
42
|
+
* statement on every boot.
|
|
43
|
+
*/
|
|
44
|
+
export declare class SchemaChangeRefusedError extends Error {
|
|
45
|
+
readonly statement: string;
|
|
46
|
+
readonly code = "REBASE_PROVISIONING_OFF";
|
|
47
|
+
constructor(statement: string);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `exec`, with every schema change refused and every other statement passed
|
|
51
|
+
* through.
|
|
52
|
+
*
|
|
53
|
+
* What a store is built on when its backend provisions nothing. The store's own
|
|
54
|
+
* bootstrap still runs, so it reads what is there exactly as it always has and
|
|
55
|
+
* reports what is missing in its own words; only the statements that would
|
|
56
|
+
* have changed the database stop here. A guard rather than a branch per store,
|
|
57
|
+
* because a branch is a thing the next store can forget.
|
|
58
|
+
*/
|
|
59
|
+
export declare function refuseSchemaChanges(exec: SqlExec): SqlExec;
|
|
60
|
+
/** {@link refuseSchemaChanges} when `provision` is `false`; `exec` itself otherwise. */
|
|
61
|
+
export declare function provisioningExec(exec: SqlExec, provision: boolean | undefined): SqlExec;
|
|
28
62
|
/**
|
|
29
63
|
* SQLSTATEs a *simultaneous boot* can raise from statements that are otherwise
|
|
30
64
|
* idempotent. Retrying is always the right answer for these: the second attempt
|
package/dist/boot/env.d.ts
CHANGED
|
@@ -34,6 +34,12 @@ export declare const bootEnvExtension: z.ZodObject<{
|
|
|
34
34
|
none: "none";
|
|
35
35
|
ensure: "ensure";
|
|
36
36
|
}>>;
|
|
37
|
+
REBASE_PROVISION: z.ZodPipe<z.ZodDefault<z.ZodEnum<{
|
|
38
|
+
"": "";
|
|
39
|
+
true: "true";
|
|
40
|
+
false: "false";
|
|
41
|
+
}>>, z.ZodTransform<boolean, "" | "true" | "false">>;
|
|
42
|
+
REBASE_PROVISION_SQL_OUT: z.ZodOptional<z.ZodString>;
|
|
37
43
|
REBASE_HSTS_INCLUDE_SUBDOMAINS: z.ZodPipe<z.ZodDefault<z.ZodEnum<{
|
|
38
44
|
"": "";
|
|
39
45
|
true: "true";
|
|
@@ -201,8 +201,12 @@ export declare function imageModulesDir(): string | undefined;
|
|
|
201
201
|
* and ran zero crons. The image installs zod (see
|
|
202
202
|
* infra/docker/server.Dockerfile); naming it here is what makes the bundle's
|
|
203
203
|
* copy collapse onto it.
|
|
204
|
+
*
|
|
205
|
+
* `hono` and `@hono/node-server` likewise: the bundler strips them, so a
|
|
206
|
+
* function importing `hono` directly found no copy under the bundle and failed
|
|
207
|
+
* to load. See `infra/docker/entrypoint.mjs`.
|
|
204
208
|
*/
|
|
205
|
-
export declare const RUNTIME_PROVIDED_PACKAGES: readonly ["@rebasepro/server", "@rebasepro/types", "@rebasepro/client", "@rebasepro/common", "@rebasepro/utils", "zod"];
|
|
209
|
+
export declare const RUNTIME_PROVIDED_PACKAGES: readonly ["@rebasepro/server", "@rebasepro/types", "@rebasepro/client", "@rebasepro/common", "@rebasepro/utils", "zod", "hono", "@hono/node-server"];
|
|
206
210
|
/**
|
|
207
211
|
* Collapse a duplicate framework copy in the bundle onto the image's.
|
|
208
212
|
*
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `REBASE_PROVISION_ONLY`, read the one way every caller reads it.
|
|
3
|
+
*
|
|
4
|
+
* Its own module, with nothing imported, so `tooling/scripts/chart-unit-roles.mts`
|
|
5
|
+
* can ask the same question of a rendered chart unit without loading the boot.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Whether this process should provision the schema and stop, rather than serve.
|
|
9
|
+
*
|
|
10
|
+
* The shape a migration Job wants: same image, same bundle, same boot — and no
|
|
11
|
+
* socket. Nothing else changes, which is the point. A separate migration image
|
|
12
|
+
* would be a second artifact to build, publish and keep in step with the
|
|
13
|
+
* runtime, and the failure mode of it drifting is a schema applied by a version
|
|
14
|
+
* that is not the one about to run against it.
|
|
15
|
+
*
|
|
16
|
+
* A blank value is *unset*: `REBASE_PROVISION_ONLY=${SOMETHING}` with SOMETHING
|
|
17
|
+
* undefined is the ordinary way to write a compose file or a Helm template, and
|
|
18
|
+
* reading it as true would turn an ordinary deployment into one that migrates
|
|
19
|
+
* and then refuses to serve.
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveProvisionOnly(env: {
|
|
22
|
+
REBASE_PROVISION_ONLY?: string;
|
|
23
|
+
}): boolean;
|
package/dist/boot/provision.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BackendBootstrapper, CollectionConfig, InitializedDriver } from "@rebasepro/types";
|
|
1
|
+
import type { BackendBootstrapper, CollectionConfig, DataDriver, InitializedDriver } from "@rebasepro/types";
|
|
2
2
|
/**
|
|
3
3
|
* Boot-time provisioning of collection tables and their RLS policies.
|
|
4
4
|
*
|
|
@@ -74,7 +74,6 @@ export interface ProvisionTarget {
|
|
|
74
74
|
*/
|
|
75
75
|
driverResult?: InitializedDriver;
|
|
76
76
|
}
|
|
77
|
-
/** `REBASE_MIGRATE_ON_BOOT=none` opts a deployment out of both phases. */
|
|
78
77
|
/**
|
|
79
78
|
* How one call to a provisioning function is run.
|
|
80
79
|
*
|
|
@@ -91,10 +90,62 @@ export interface ProvisionRunOptions {
|
|
|
91
90
|
};
|
|
92
91
|
/** Default `true`. `false` leaves every DDL statement to another process. */
|
|
93
92
|
provision?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Why `provision` is `false`, when it is — phrased to follow "skipped —".
|
|
95
|
+
* Defaults to the split-deployment reason, which is the only one that
|
|
96
|
+
* existed before a backend could be told to provision nothing at all.
|
|
97
|
+
*/
|
|
98
|
+
skipReason?: string;
|
|
94
99
|
}
|
|
100
|
+
/** `REBASE_MIGRATE_ON_BOOT=none` opts a deployment out of both phases. */
|
|
95
101
|
export declare function provisioningDisabled(env?: {
|
|
96
102
|
REBASE_MIGRATE_ON_BOOT?: string;
|
|
97
103
|
}): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* `REBASE_PROVISION`: whether this deployment may change its own database.
|
|
106
|
+
*
|
|
107
|
+
* `false` is the answer for a database whose schema changes only through a
|
|
108
|
+
* change process somebody else runs. The backend is then built with
|
|
109
|
+
* `provision: false`: no table, column, index, type, function, trigger,
|
|
110
|
+
* policy, role or grant is created or altered — at boot, or later by a request
|
|
111
|
+
* that would have created a table on first use — and none of the bookkeeping
|
|
112
|
+
* writes a provisioning boot makes (the first admin account, version stamps)
|
|
113
|
+
* happens either. What the database lacks is reported, by name, instead.
|
|
114
|
+
*
|
|
115
|
+
* Wider than `REBASE_MIGRATE_ON_BOOT=none`, and deliberately a second
|
|
116
|
+
* variable rather than a new meaning for that one. `none` has only ever turned
|
|
117
|
+
* off the *collection* schema; the framework's own tables stayed current on
|
|
118
|
+
* every boot, and deployments rely on that — the chart's migration-Job
|
|
119
|
+
* topology runs every api pod with `none`. Widening `none` would stop those
|
|
120
|
+
* pods keeping `rebase.*` current on the next upgrade, silently.
|
|
121
|
+
*
|
|
122
|
+
* A blank value is unset, for the same reason as `REBASE_PROVISION_ONLY`: a
|
|
123
|
+
* compose file writes `${REBASE_PROVISION}` with nothing behind it all the
|
|
124
|
+
* time. Anything else that is not a yes or a no is refused rather than
|
|
125
|
+
* guessed at — read as `true`, a typo would leave a change-managed database
|
|
126
|
+
* being changed.
|
|
127
|
+
*/
|
|
128
|
+
export declare function resolveProvisionEnv(env?: {
|
|
129
|
+
REBASE_PROVISION?: string;
|
|
130
|
+
}): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Create, now, the tables a serving process would otherwise create on first
|
|
133
|
+
* use: the idempotency keys every keyed write needs, the cron tables the
|
|
134
|
+
* hourly sweeps claim their slots in, and the shared rate-limit counters when
|
|
135
|
+
* `REBASE_RATE_LIMIT_STORE=sql` asks for them.
|
|
136
|
+
*
|
|
137
|
+
* What a provisioning run calls. A deployment whose serving processes run with
|
|
138
|
+
* `REBASE_PROVISION=false` never creates anything, so this run is the only
|
|
139
|
+
* place those tables can come from — and its recorded SQL is only the whole
|
|
140
|
+
* schema those processes need if it made them.
|
|
141
|
+
*/
|
|
142
|
+
export declare function provisionFirstUseTables(driver: DataDriver, env?: {
|
|
143
|
+
REBASE_RATE_LIMIT_STORE?: string;
|
|
144
|
+
}): Promise<void>;
|
|
145
|
+
/** The one line a backend that provisions nothing says about it, at boot. */
|
|
146
|
+
export declare const PROVISIONING_OFF_NOTICE: string;
|
|
147
|
+
/** Why each provisioning step skips on such a backend, phrased to follow "skipped —". */
|
|
148
|
+
export declare const PROVISIONING_OFF_SKIP_REASON = "this backend changes nothing in its database (provisioning is off), so the schema is left to whatever manages it.";
|
|
98
149
|
/**
|
|
99
150
|
* Ask the database whether it is there, before boot's first real query.
|
|
100
151
|
*
|
package/dist/boot/role.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface RoleEnv {
|
|
|
38
38
|
/** Opt in to the MCP surface. No role grants it — see `resolveRole`. */
|
|
39
39
|
REBASE_MCP_ENABLED?: boolean;
|
|
40
40
|
REBASE_MIGRATE_ON_BOOT?: "none" | "ensure" | "push" | "";
|
|
41
|
+
/** `false`: this process changes nothing in its database. See `resolveProvisionEnv`. */
|
|
42
|
+
REBASE_PROVISION?: boolean;
|
|
41
43
|
REBASE_FUNCTIONS_ONLY?: string;
|
|
42
44
|
REBASE_FUNCTIONS_EXCLUDE?: string;
|
|
43
45
|
REBASE_FUNCTIONS_UPSTREAM?: string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { PoolConnect } from "../metrics/pool-wait.js";
|
|
2
|
+
/**
|
|
3
|
+
* Split a query string into its statements, at every `;` that is not inside a
|
|
4
|
+
* string, a quoted identifier, a dollar-quoted body or a comment.
|
|
5
|
+
*
|
|
6
|
+
* Boot sends several statements as one string — `SET LOCAL lock_timeout`, then
|
|
7
|
+
* a table and its indexes, committed together — so a recording that looked
|
|
8
|
+
* only at the first word of each query would miss every table it made that
|
|
9
|
+
* way. Dollar quotes are the case that matters: a `DO` block or a function
|
|
10
|
+
* body is full of semicolons that end nothing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function splitStatements(sqlText: string): string[];
|
|
13
|
+
export interface SchemaChangeRecorder {
|
|
14
|
+
/** Every schema change completed so far, in the order the database finished them. */
|
|
15
|
+
statements(): string[];
|
|
16
|
+
/** Stop recording. The pools keep working exactly as before. */
|
|
17
|
+
stop(): void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Record every schema change completed on connections checked out of `pools`.
|
|
21
|
+
*
|
|
22
|
+
* Wraps `connect` on each pool object, and `query` on each connection it hands
|
|
23
|
+
* out — never a prototype, so nothing outside these pools is touched. Must be
|
|
24
|
+
* installed before the first statement: a `pg.Pool` connects lazily, so a pool
|
|
25
|
+
* that has just been created has nothing open yet, and `pool.query` checks out
|
|
26
|
+
* through `connect` like everything else.
|
|
27
|
+
*/
|
|
28
|
+
export declare function recordSchemaChanges(pools: ReadonlyArray<{
|
|
29
|
+
connect?: PoolConnect;
|
|
30
|
+
}>): SchemaChangeRecorder;
|
|
31
|
+
/** Where the run happened, for the script's header — never the credentials. */
|
|
32
|
+
export declare function describeDatabase(connectionString: string | undefined): string | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The script a change process applies: a header saying what it is and how to
|
|
35
|
+
* run it, then every recorded statement in order.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately free of anything that changes between two runs of the same
|
|
38
|
+
* release against the same state — no timestamp — so that two scripts can be
|
|
39
|
+
* diffed, and a reviewer comparing this release's script with the last one
|
|
40
|
+
* sees only what the release changed.
|
|
41
|
+
*/
|
|
42
|
+
export declare function renderSchemaChangeScript(statements: readonly string[], context: {
|
|
43
|
+
runtimeVersion?: string;
|
|
44
|
+
database?: string;
|
|
45
|
+
}): string;
|
|
@@ -20,6 +20,28 @@ export declare function strictTransportSecurity(includeSubDomains: boolean): str
|
|
|
20
20
|
* that broke any of them would be one every project had to turn off.
|
|
21
21
|
*/
|
|
22
22
|
export declare const STATIC_APP_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
|
|
23
|
+
/**
|
|
24
|
+
* The `Referrer-Policy` this runtime sends: `strict-origin-when-cross-origin`,
|
|
25
|
+
* the browsers' own default. A cross-origin request carries the page's origin
|
|
26
|
+
* and never its path or query.
|
|
27
|
+
*
|
|
28
|
+
* Not `no-referrer`, the `secureHeaders()` default it used to send: an origin
|
|
29
|
+
* check then sees no origin at all. Google's "Sign in with Google" button is the
|
|
30
|
+
* one that bit — it compares the page's origin with the OAuth client's
|
|
31
|
+
* authorized JavaScript origins, and under `no-referrer` it refused every
|
|
32
|
+
* Rebase-hosted app with "The given origin is not allowed for the given client
|
|
33
|
+
* ID", whatever the client allowed.
|
|
34
|
+
*/
|
|
35
|
+
export declare const DEFAULT_REFERRER_POLICY = "strict-origin-when-cross-origin";
|
|
36
|
+
/**
|
|
37
|
+
* Sends `Referrer-Policy: strict-origin-when-cross-origin` and
|
|
38
|
+
* `X-Frame-Options: SAMEORIGIN` on every response that does not set its own.
|
|
39
|
+
*
|
|
40
|
+
* Use it beside `secureHeaders({ referrerPolicy: false, xFrameOptions: false })`;
|
|
41
|
+
* left on, those two options overwrite the route's value. A static app that
|
|
42
|
+
* wants a stricter referrer policy says so with `<meta name="referrer">`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function overridableSecurityHeaders(): MiddlewareHandler;
|
|
23
45
|
/** The security headers both boot paths — backend and static-only — install. */
|
|
24
46
|
export declare function runtimeSecureHeaders(options: {
|
|
25
47
|
hstsIncludeSubDomains: boolean;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createRequire as __rebaseCreateRequire } from "module";
|
|
2
|
+
import __rebaseProcess from "process";
|
|
3
|
+
globalThis.process ??= __rebaseProcess;
|
|
4
|
+
__rebaseCreateRequire(import.meta.url);
|
|
5
|
+
import "./src-Bh9LRPb9.js";
|
|
6
|
+
import { t as isBranchAdmin } from "./backend-CaG_Jyso.js";
|
|
7
|
+
import { a as logger } from "./logger-CW8pmTS6.js";
|
|
8
|
+
import { t as ApiError } from "./errors-OMWkKSH-.js";
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
//#region src/api/branch-routes.ts
|
|
11
|
+
function optionalInteger(value, name) {
|
|
12
|
+
if (value === void 0 || value === "") return void 0;
|
|
13
|
+
const parsed = Number(value);
|
|
14
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw ApiError.badRequest(`\`${name}\` must be a non-negative integer.`, "INVALID_INPUT");
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
function schemaList(value) {
|
|
18
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
19
|
+
const list = typeof value === "string" ? value.split(",") : value;
|
|
20
|
+
if (!Array.isArray(list) || !list.every((item) => typeof item === "string")) throw ApiError.badRequest("`schemas` must be a list of schema names.", "INVALID_INPUT");
|
|
21
|
+
const names = list.map((item) => item.trim()).filter((item) => item !== "");
|
|
22
|
+
return names.length > 0 ? names : void 0;
|
|
23
|
+
}
|
|
24
|
+
function readMergeRequest(body) {
|
|
25
|
+
if (typeof body !== "object" || body === null || !("expectedFingerprint" in body) || typeof body.expectedFingerprint !== "string" || body.expectedFingerprint === "") throw ApiError.badRequest("Send `expectedFingerprint`, the `fingerprint` of the diff that was reviewed.", "INVALID_INPUT");
|
|
26
|
+
const field = (key) => key in body ? Reflect.get(body, key) : void 0;
|
|
27
|
+
const onConflict = field("onConflict");
|
|
28
|
+
if (onConflict !== void 0 && onConflict !== "abort" && onConflict !== "branch" && onConflict !== "target") throw ApiError.badRequest("`onConflict` must be \"abort\", \"branch\" or \"target\".", "INVALID_INPUT");
|
|
29
|
+
const skipBlocked = field("skipBlocked");
|
|
30
|
+
if (skipBlocked !== void 0 && typeof skipBlocked !== "boolean") throw ApiError.badRequest("`skipBlocked` must be a boolean.", "INVALID_INPUT");
|
|
31
|
+
const maxRows = field("maxRowsPerTable");
|
|
32
|
+
if (maxRows !== void 0 && (typeof maxRows !== "number" || !Number.isInteger(maxRows) || maxRows < 1)) throw ApiError.badRequest("`maxRowsPerTable` must be a positive integer.", "INVALID_INPUT");
|
|
33
|
+
return {
|
|
34
|
+
expectedFingerprint: body.expectedFingerprint,
|
|
35
|
+
onConflict,
|
|
36
|
+
skipBlocked,
|
|
37
|
+
schemas: schemaList(field("schemas")),
|
|
38
|
+
maxRowsPerTable: maxRows
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function createBranchRoutes(options) {
|
|
42
|
+
const router = new Hono();
|
|
43
|
+
const branchAdmin = () => {
|
|
44
|
+
const admin = options.getAdmin();
|
|
45
|
+
if (!isBranchAdmin(admin) || typeof admin.diffBranch !== "function" || typeof admin.mergeBranch !== "function") throw new ApiError(501, "BRANCH_DIFF_UNSUPPORTED", "This data source cannot compare branches. Branching needs a Postgres backend with an admin connection.");
|
|
46
|
+
return {
|
|
47
|
+
diffBranch: admin.diffBranch.bind(admin),
|
|
48
|
+
mergeBranch: admin.mergeBranch.bind(admin)
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
router.get("/:name/diff", async (c) => {
|
|
52
|
+
const { diffBranch } = branchAdmin();
|
|
53
|
+
const diffOptions = {
|
|
54
|
+
sampleLimit: optionalInteger(c.req.query("sampleLimit"), "sampleLimit"),
|
|
55
|
+
maxRowsPerTable: optionalInteger(c.req.query("maxRowsPerTable"), "maxRowsPerTable"),
|
|
56
|
+
schemas: schemaList(c.req.query("schemas"))
|
|
57
|
+
};
|
|
58
|
+
return c.json(await diffBranch(c.req.param("name"), diffOptions));
|
|
59
|
+
});
|
|
60
|
+
router.post("/:name/merge", async (c) => {
|
|
61
|
+
const { mergeBranch } = branchAdmin();
|
|
62
|
+
const name = c.req.param("name");
|
|
63
|
+
const request = readMergeRequest(await c.req.json().catch(() => null));
|
|
64
|
+
const caller = c.get("user");
|
|
65
|
+
const user = typeof caller === "object" && caller !== null ? caller : void 0;
|
|
66
|
+
const result = await mergeBranch(name, request);
|
|
67
|
+
logger.info("[Branch Audit] Branch merged", {
|
|
68
|
+
branch: name,
|
|
69
|
+
target: result.targetDatabase,
|
|
70
|
+
tables: result.tables.length,
|
|
71
|
+
rows: result.tables.reduce((sum, table) => sum + table.inserted + table.updated + table.deleted, 0),
|
|
72
|
+
onConflict: request.onConflict ?? "abort",
|
|
73
|
+
uid: user?.uid ?? "unknown",
|
|
74
|
+
...c.get("apiKey") ? { apiKey: c.get("apiKey")?.id } : {},
|
|
75
|
+
requestId: c.get("requestId")
|
|
76
|
+
});
|
|
77
|
+
return c.json(result);
|
|
78
|
+
});
|
|
79
|
+
return router;
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
export { createBranchRoutes };
|
|
83
|
+
|
|
84
|
+
//# sourceMappingURL=branch-routes-B2vvEx53.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"branch-routes-B2vvEx53.js","names":[],"sources":["../src/api/branch-routes.ts"],"sourcesContent":["import { Hono } from \"hono\";\nimport { isBranchAdmin, type BranchDiffOptions, type BranchMergeOptions, type DatabaseAdmin } from \"@rebasepro/types\";\nimport { ApiError } from \"./errors\";\nimport type { HonoEnv } from \"./types\";\nimport { logger } from \"../utils/logger\";\n\n/**\n * Studio's review of a database branch: what it changed, and merging it.\n *\n * Mounted under `/api/admin/branches` behind the admin gate — `database:write`\n * for both: the diff's sample rows are read as the database owner, outside\n * row-level security, from both databases.\n *\n * Engine-agnostic: the comparison is the driver's (`BranchAdmin.diffBranch`,\n * `mergeBranch`), and a driver without it answers 501. The driver's refusals\n * are `ApiError`s that carry their own status — 404 for a branch that is not\n * there, 409 for a diff that went stale or a merge that would conflict.\n *\n * ```\n * GET /:name/diff?sampleLimit=100&schemas=public,app&maxRowsPerTable=100000\n * POST /:name/merge { expectedFingerprint, onConflict?, skipBlocked?, schemas?, maxRowsPerTable? }\n * ```\n */\nexport interface BranchRoutesOptions {\n /** The default data source's admin capabilities, read per request. */\n getAdmin: () => DatabaseAdmin | undefined;\n}\n\nfunction optionalInteger(value: string | undefined, name: string): number | undefined {\n if (value === undefined || value === \"\") return undefined;\n const parsed = Number(value);\n if (!Number.isInteger(parsed) || parsed < 0) {\n throw ApiError.badRequest(`\\`${name}\\` must be a non-negative integer.`, \"INVALID_INPUT\");\n }\n return parsed;\n}\n\nfunction schemaList(value: unknown): string[] | undefined {\n if (value === undefined || value === null || value === \"\") return undefined;\n const list = typeof value === \"string\" ? value.split(\",\") : value;\n if (!Array.isArray(list) || !list.every((item) => typeof item === \"string\")) {\n throw ApiError.badRequest(\"`schemas` must be a list of schema names.\", \"INVALID_INPUT\");\n }\n const names = list.map((item: string) => item.trim()).filter((item) => item !== \"\");\n return names.length > 0 ? names : undefined;\n}\n\nfunction readMergeRequest(body: unknown): BranchMergeOptions {\n if (typeof body !== \"object\" || body === null || !(\"expectedFingerprint\" in body)\n || typeof body.expectedFingerprint !== \"string\" || body.expectedFingerprint === \"\") {\n throw ApiError.badRequest(\n \"Send `expectedFingerprint`, the `fingerprint` of the diff that was reviewed.\",\n \"INVALID_INPUT\"\n );\n }\n const field = (key: string): unknown => key in body ? Reflect.get(body, key) : undefined;\n const onConflict = field(\"onConflict\");\n if (onConflict !== undefined && onConflict !== \"abort\" && onConflict !== \"branch\" && onConflict !== \"target\") {\n throw ApiError.badRequest(\"`onConflict` must be \\\"abort\\\", \\\"branch\\\" or \\\"target\\\".\", \"INVALID_INPUT\");\n }\n const skipBlocked = field(\"skipBlocked\");\n if (skipBlocked !== undefined && typeof skipBlocked !== \"boolean\") {\n throw ApiError.badRequest(\"`skipBlocked` must be a boolean.\", \"INVALID_INPUT\");\n }\n const maxRows = field(\"maxRowsPerTable\");\n if (maxRows !== undefined && (typeof maxRows !== \"number\" || !Number.isInteger(maxRows) || maxRows < 1)) {\n throw ApiError.badRequest(\"`maxRowsPerTable` must be a positive integer.\", \"INVALID_INPUT\");\n }\n return {\n expectedFingerprint: body.expectedFingerprint,\n onConflict,\n skipBlocked,\n schemas: schemaList(field(\"schemas\")),\n maxRowsPerTable: maxRows\n };\n}\n\nexport function createBranchRoutes(options: BranchRoutesOptions): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n\n const branchAdmin = () => {\n const admin = options.getAdmin();\n if (!isBranchAdmin(admin) || typeof admin.diffBranch !== \"function\" || typeof admin.mergeBranch !== \"function\") {\n throw new ApiError(501, \"BRANCH_DIFF_UNSUPPORTED\",\n \"This data source cannot compare branches. Branching needs a Postgres backend with an admin connection.\");\n }\n return { diffBranch: admin.diffBranch.bind(admin), mergeBranch: admin.mergeBranch.bind(admin) };\n };\n\n router.get(\"/:name/diff\", async (c) => {\n const { diffBranch } = branchAdmin();\n const diffOptions: BranchDiffOptions = {\n sampleLimit: optionalInteger(c.req.query(\"sampleLimit\"), \"sampleLimit\"),\n maxRowsPerTable: optionalInteger(c.req.query(\"maxRowsPerTable\"), \"maxRowsPerTable\"),\n schemas: schemaList(c.req.query(\"schemas\"))\n };\n return c.json(await diffBranch(c.req.param(\"name\"), diffOptions));\n });\n\n router.post(\"/:name/merge\", async (c) => {\n const { mergeBranch } = branchAdmin();\n const name = c.req.param(\"name\");\n const request = readMergeRequest(await c.req.json().catch(() => null));\n const caller = c.get(\"user\");\n const user = typeof caller === \"object\" && caller !== null ? caller : undefined;\n const result = await mergeBranch(name, request);\n // Writes to the main database on someone's say-so: the audit line is\n // the record of whose.\n logger.info(\"[Branch Audit] Branch merged\", {\n branch: name,\n target: result.targetDatabase,\n tables: result.tables.length,\n rows: result.tables.reduce((sum, table) => sum + table.inserted + table.updated + table.deleted, 0),\n onConflict: request.onConflict ?? \"abort\",\n uid: user?.uid ?? \"unknown\",\n ...(c.get(\"apiKey\") ? { apiKey: c.get(\"apiKey\")?.id } : {}),\n requestId: c.get(\"requestId\")\n });\n return c.json(result);\n });\n\n return router;\n}\n"],"mappings":"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"}
|