From a3605bae7d987008e1125a8bce21f05272edc42d Mon Sep 17 00:00:00 2001 From: M Date: Sun, 30 Aug 2026 20:34:33 +0000 Subject: [PATCH] feat: add persistent staging environment sync --- .env.example | 5 + .env.production.example | 4 + compose.prod.yml | 2 + docs/deployment/README.md | 5 +- docs/deployment/docker-image.md | 2 + docs/deployment/staging.md | 43 +++ docs/features/site-transfer.md | 10 +- docs/reference/capabilities.md | 3 +- server/auth/capabilities.ts | 1 + server/config.ts | 7 + server/db/migrations-pg.ts | 22 ++ server/db/migrations-sqlite.ts | 22 ++ server/handlers/cms/import.ts | 84 +++++- server/handlers/cms/index.ts | 2 + server/handlers/cms/staging.ts | 172 +++++++++++ server/index.ts | 2 + server/repositories/audit.ts | 3 + server/repositories/stagingEnvironment.ts | 193 +++++++++++++ server/repositories/users.ts | 13 + server/router.ts | 18 ++ server/staging/bundle.ts | 33 +++ server/staging/receiver.ts | 87 ++++++ src/__tests__/server/serverConfig.test.ts | 12 + .../server/stagingEnvironment.test.ts | 151 ++++++++++ .../server/stagingSelectedImport.test.ts | 71 +++++ src/__tests__/settings/settingsModal.test.tsx | 7 +- .../modals/Settings/SettingsModal.module.css | 95 +++++++ src/admin/modals/Settings/SettingsModal.tsx | 21 +- .../Settings/sections/StagingSection.tsx | 268 ++++++++++++++++++ .../SiteImport/shared/useCmsBundleImport.ts | 3 +- .../dashboard/widgets/ActivityWidget.tsx | 6 + .../pages/site/store/slices/settingsSlice.ts | 1 + src/admin/pages/users/utils/audit.ts | 6 + src/admin/pages/users/utils/capabilities.ts | 7 +- .../shared/CapabilityPicker/capabilityMeta.ts | 4 + src/core/capabilities.ts | 2 + src/core/data/bundleSchema.ts | 2 +- src/core/persistence/cmsStaging.ts | 57 ++++ src/core/persistence/index.ts | 7 + src/core/staging/index.ts | 14 + src/core/staging/schemas.ts | 58 ++++ 41 files changed, 1510 insertions(+), 15 deletions(-) create mode 100644 docs/deployment/staging.md create mode 100644 server/handlers/cms/staging.ts create mode 100644 server/repositories/stagingEnvironment.ts create mode 100644 server/staging/bundle.ts create mode 100644 server/staging/receiver.ts create mode 100644 src/__tests__/server/stagingEnvironment.test.ts create mode 100644 src/__tests__/server/stagingSelectedImport.test.ts create mode 100644 src/admin/modals/Settings/sections/StagingSection.tsx create mode 100644 src/core/persistence/cmsStaging.ts create mode 100644 src/core/staging/index.ts create mode 100644 src/core/staging/schemas.ts diff --git a/.env.example b/.env.example index 33fe0d113..7b489083d 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,8 @@ STATIC_DIR=./dist # bun run scripts/generate-secret-key.ts # # INSTATIC_SECRET_KEY= + +# Staging receiver. Set these only on the separate staging instance, then use +# Settings > Staging on production to connect its public HTTPS origin. +# INSTATIC_ENVIRONMENT=staging +# STAGING_SYNC_TOKEN= diff --git a/.env.production.example b/.env.production.example index b9254dbb1..643c53362 100644 --- a/.env.production.example +++ b/.env.production.example @@ -26,6 +26,10 @@ INSTATIC_IMAGE=ghcr.io/corebunch/instatic:latest # bun run scripts/generate-secret-key.ts INSTATIC_SECRET_KEY=replace-with-output-of-generate-secret-key +# Set only on a separate staging instance that should accept database refreshes. +# INSTATIC_ENVIRONMENT=staging +# STAGING_SYNC_TOKEN=replace-with-a-long-random-token + # ─── Networking ────────────────────────────────────────────────────────────── # HOST_PORT is the port the app is exposed on directly (no TLS). # When you layer compose.tls.yml on top, Caddy listens on 80/443 instead and diff --git a/compose.prod.yml b/compose.prod.yml index 17154ab0d..aac203aea 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -31,6 +31,8 @@ services: STATIC_DIR: /app/dist INSTATIC_SECRET_KEY: ${INSTATIC_SECRET_KEY:-} TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-} + INSTATIC_ENVIRONMENT: ${INSTATIC_ENVIRONMENT:-production} + STAGING_SYNC_TOKEN: ${STAGING_SYNC_TOKEN:-} volumes: - uploads:/app/uploads depends_on: diff --git a/docs/deployment/README.md b/docs/deployment/README.md index d343eeb04..1594c5ca6 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -2,7 +2,7 @@ This index maps supported deployment targets to the files, variables, and persistence rules they need. -Instatic is one Bun server packaged by the root `Dockerfile`. The server reads runtime configuration from `server/config.ts`: `PORT`, `DATABASE_URL`, `UPLOADS_DIR`, `STATIC_DIR`, `PUBLIC_ORIGIN`, and `TRUSTED_PROXY_CIDRS`. Reversible server secrets, including AI provider credentials, plugin secret settings, and MFA TOTP seeds, are encrypted with `INSTATIC_SECRET_KEY` when configured. Database migrations run automatically on boot in `server/index.ts`. +Instatic is one Bun server packaged by the root `Dockerfile`. The server reads runtime configuration from `server/config.ts`: `PORT`, `DATABASE_URL`, `UPLOADS_DIR`, `STATIC_DIR`, `PUBLIC_ORIGIN`, and `TRUSTED_PROXY_CIDRS`. Reversible server secrets, including AI provider credentials, plugin secret settings, MFA TOTP seeds, and staging connection tokens, are encrypted with `INSTATIC_SECRET_KEY` when configured. Database migrations run automatically on boot in `server/index.ts`. --- @@ -31,6 +31,8 @@ UPLOADS_DIR directory for media, plugin packs, fonts, and published disk artef STATIC_DIR built admin SPA directory; /app/dist in the Docker image INSTATIC_SECRET_KEY base64 32-byte key for encrypted server secrets PUBLIC_ORIGIN comma-separated public origin(s) the CSRF check trusts; auto-detected from RENDER_EXTERNAL_URL / RAILWAY_PUBLIC_DOMAIN on those platforms +INSTATIC_ENVIRONMENT set to staging only on a staging instance +STAGING_SYNC_TOKEN receiver credential; required when INSTATIC_ENVIRONMENT=staging TRUSTED_PROXY_CIDRS optional; trusts proxy socket peers for forwarded client-IP attribution only (audit logs, rate-limit keys) — NOT used for CSRF ``` @@ -103,6 +105,7 @@ SQLite installs also need the SQLite database file on persistent storage. On pla | [docker-image.md](docker-image.md) | Generic Docker image contract and `docker run` examples | | [tls-caddy.md](tls-caddy.md) | Caddy TLS overlay for VPS Compose installs | | [backup-restore.md](backup-restore.md) | Database and uploads backup/restore | +| [staging.md](staging.md) | Persistent staging instance and one-click database refresh | | [release-workflow.md](release-workflow.md) | Maintainer image publishing workflow | ## Related diff --git a/docs/deployment/docker-image.md b/docs/deployment/docker-image.md index c8ec22ff7..7d0a0484e 100644 --- a/docs/deployment/docker-image.md +++ b/docs/deployment/docker-image.md @@ -141,6 +141,8 @@ Render auto-injects `RENDER_EXTERNAL_URL`, which Instatic uses as the CSRF publi | `STATIC_DIR` | Yes in Docker | `/app/dist` | | `PORT` | Platform-dependent | HTTP listen port; defaults to `3001` | | `INSTATIC_SECRET_KEY` | Yes for reversible server secrets | Output of `bun run scripts/generate-secret-key.ts` | +| `INSTATIC_ENVIRONMENT` | Staging receiver only | Set to `staging` on the separate staging instance; defaults to `production` | +| `STAGING_SYNC_TOKEN` | Staging receiver only | High-entropy bearer token shared once with production through Settings > Staging | | `PUBLIC_ORIGIN` | Behind managed HTTPS proxies | Comma-separated public origins for the CSRF check, e.g. `https://www.example.com`. Auto-detected from `RENDER_EXTERNAL_URL` / `RAILWAY_PUBLIC_DOMAIN` on those platforms | | `TRUSTED_PROXY_CIDRS` | Optional | Comma-separated trusted proxy CIDRs for client-IP attribution only (audit logs, rate-limit keys) — **not** used for CSRF. Trust only your real proxy CIDRs; never `0.0.0.0/0` for a public service | diff --git a/docs/deployment/staging.md b/docs/deployment/staging.md new file mode 100644 index 000000000..c4f8e8f59 --- /dev/null +++ b/docs/deployment/staging.md @@ -0,0 +1,43 @@ +# Staging Environment + +Instatic staging uses two independent instances: production keeps its own database and uploads, while staging runs continuously against a second database on a separate origin such as `https://staging.example.com`. Production pushes a validated site-transfer payload to staging only when an authorized operator requests a refresh. + +## Configure the staging instance + +Deploy the same Instatic version as production with a separate `DATABASE_URL`, a separate persistent `UPLOADS_DIR`, and its own `INSTATIC_SECRET_KEY`. Route the staging subdomain to that service, then set: + +```txt +INSTATIC_ENVIRONMENT=staging +STAGING_SYNC_TOKEN= +PUBLIC_ORIGIN=https://staging.example.com +``` + +Generate the receiver token with a cryptographically secure secret generator, for example `openssl rand -hex 32`. The receiver route returns 404 unless both staging mode and a token are configured. It accepts only a constant-time-checked bearer token and is not authenticated by an admin browser session. + +Complete the staging instance's setup wizard once so it has an active owner. A refresh imports the selected production state and republishes it using that local owner identity. + +## Connect production + +Open **Settings > Staging** on production and enter the staging origin, the exact receiver token, whether to include the site shell, and either all data tables or a selected table set. Save the configuration, then use **Test connection**. + +The token is encrypted with production's `INSTATIC_SECRET_KEY` and is never returned by the API. If that key changes, the UI requires the token to be entered again. + +## Refresh behavior + +**All database tables** uses the existing full replacement import: staging rows and custom table definitions absent from production are removed. **Selected tables** replaces only the selected tables and their rows; other staging tables remain untouched. Redirects targeting synchronized rows travel with the payload. The site shell is optional in either mode. + +After the import commits, staging runs the normal full-site publish pipeline so the subdomain updates in the same request. Configuration changes and refreshes require the `deployment.manage` capability and a fresh step-up authentication window. Each action is recorded in the audit log, and the latest refresh status is persisted for the Settings screen. + +Uploaded media bytes are not copied by database refresh. Keep staging uploads on separate persistent storage and mirror the required files with the normal backup/storage tooling when production content references local media. This prevents a database refresh from silently overwriting or deleting an independently managed staging media volume. + +The validated JSON database payload is limited to 64 MiB. For a larger dataset, +reduce the refresh to selected tables or use the normal database backup and restore +workflow for the initial staging seed. + +## Safety rules + +- Never set `INSTATIC_ENVIRONMENT=staging` on production. +- Use HTTPS for remote staging origins. Plain HTTP is accepted only for loopback development addresses. +- Give production and staging different databases, upload volumes, and `INSTATIC_SECRET_KEY` values. +- Rotate `STAGING_SYNC_TOKEN` immediately if it is exposed, then update the saved production configuration. +- Keep both instances on the same release before refreshing; the standard migration runner should complete on staging first. diff --git a/docs/features/site-transfer.md b/docs/features/site-transfer.md index 688fe96a5..4408cbb79 100644 --- a/docs/features/site-transfer.md +++ b/docs/features/site-transfer.md @@ -42,7 +42,7 @@ src/core/data/bundleSchema.ts ├── MediaAssetExportSchema — internal import payload asset with bytesBase64 + folderIds ├── BundleMediaFolderSchema — one media-library folder (tree via parentId) ├── BundleRedirectSchema — one published-URL redirect (raw row) -├── ImportStrategySchema — 'replace' | 'merge-add' | 'merge-overwrite' +├── ImportStrategySchema — user-facing import strategies plus internal staging replacement ├── ExportRequestSchema — POST /export body ├── ExportEstimateSchema — GET/POST /export/estimate response ├── ExportSummarySchema — GET /export/summary response (category counts) @@ -309,6 +309,14 @@ POST /admin/api/cms/export } ``` +The import engine also has an internal `replace-selected` mode reserved for staging +synchronization. It replaces only the tables present in the validated bundle and +preserves every other destination table; it is not accepted by the public import API. + +`replace-selected` is reserved for staging synchronization. It replaces only the +tables present in the validated bundle and preserves every other destination table; +the Site Import modal continues to expose the three interactive strategies. + Save the response ZIP to disk (browser handles the download automatically). ### Move a site between hosts diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index d625185cf..4b75af963 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -8,7 +8,7 @@ For the broader auth flow (sessions, MFA, step-up), see [docs/features/auth-and- ## TL;DR -- Defined as a `const` array in `src/core/capabilities.ts` (`@core/capabilities`); `CoreCapability` is derived via `typeof CORE_CAPABILITIES[number]`. **38 capabilities.** +- Defined as a `const` array in `src/core/capabilities.ts` (`@core/capabilities`); `CoreCapability` is derived via `typeof CORE_CAPABILITIES[number]`. **39 capabilities.** - Handlers gate on capability, not on role: `requireCapability(req, db, 'site.read')`. - The **Owner AND Admin** roles get their capability lists force-resynced from `SYSTEM_ROLES` on every server boot. Hand-edits to either built-in role through the admin UI are restored at next boot — they are code-level decisions, not runtime ones. - Adding a capability: append the literal to `CORE_CAPABILITIES` in `src/core/capabilities.ts` (one place — server imports it), add it to the relevant `SYSTEM_ROLES` entries, wire `requireCapability(...)` at the gate point, and add picker meta + groups for the role-edit dialog. The two architecture tests (`capability-picker-coverage.test.ts`, `cms-handlers-capability-gated.test.ts`) catch missing pieces. @@ -99,6 +99,7 @@ Was a single `runtime.manage`. Split because adapter election (bytes go to a plu | `runtime.dependencies` | Edit site `package.json` dependencies; trigger `POST /runtime/dependencies/resolve`. | Owner, Admin | | `storage.elect` | Elect a media storage adapter per asset role (originals / variants / avatars / fonts); elect/clear the variant delegate; verify adapter credentials. | Owner, Admin | | `storage.migrate` | Run the migration SSE that moves bytes between adapters after an election change. | Owner, Admin | +| `deployment.manage` | Configure, test, and refresh a persistent staging environment. Save and refresh actions require step-up. | Owner, Admin | ### Plugins (granular split) diff --git a/server/auth/capabilities.ts b/server/auth/capabilities.ts index a91f84387..1cafc0a01 100644 --- a/server/auth/capabilities.ts +++ b/server/auth/capabilities.ts @@ -74,6 +74,7 @@ const adminCapabilities: CoreCapability[] = [ 'data.rows.move', 'data.export', 'data.import', + 'deployment.manage', 'ai.chat', 'ai.tools.write', 'ai.providers.manage', diff --git a/server/config.ts b/server/config.ts index 1e9ae55a2..89bbd680d 100644 --- a/server/config.ts +++ b/server/config.ts @@ -5,6 +5,8 @@ interface ServerConfig { staticDir: string trustedProxyCidrs: string[] publicOrigins: string[] + environment: 'production' | 'staging' + stagingSyncToken?: string } function readCsvList(value: string | undefined): string[] { @@ -88,6 +90,7 @@ export function resolvePublicOrigins(env: Record): s export function readServerConfig( env: Record = process.env, ): ServerConfig { + const environment = env.INSTATIC_ENVIRONMENT === 'staging' ? 'staging' : 'production' return { port: Number(env.PORT ?? 3001), databaseUrl: env.DATABASE_URL ?? 'sqlite:./.tmp/dev.db', @@ -95,5 +98,9 @@ export function readServerConfig( staticDir: env.STATIC_DIR ?? './dist', trustedProxyCidrs: readCsvList(env.TRUSTED_PROXY_CIDRS), publicOrigins: resolvePublicOrigins(env), + environment, + ...(env.STAGING_SYNC_TOKEN?.trim() + ? { stagingSyncToken: env.STAGING_SYNC_TOKEN.trim() } + : {}), } } diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index d129ffaa2..dccaa867e 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1156,4 +1156,26 @@ export const pgMigrations: Migration[] = [ where trim(lower(display_name)) = trim(lower(email)); `, }, + { + id: '025_staging_environment', + sql: ` + create table if not exists staging_environment ( + id integer primary key check (id = 1), + origin text not null, + token_ciphertext bytea not null, + token_iv bytea not null, + key_fingerprint text not null, + table_ids_json jsonb not null default '[]'::jsonb, + include_site boolean not null default true, + created_by_user_id text references users(id) on delete set null, + created_at timestamptz not null default current_timestamp, + updated_at timestamptz not null default current_timestamp, + last_sync_at timestamptz, + last_sync_status text, + last_sync_error text, + constraint staging_environment_sync_status_check + check (last_sync_status is null or last_sync_status in ('success', 'failed')) + ); + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index a4c5cdd74..f4158e436 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1224,4 +1224,26 @@ export const sqliteMigrations: Migration[] = [ where trim(lower(display_name)) = trim(lower(email)); `, }, + { + id: '025_staging_environment', + sql: ` + create table if not exists staging_environment ( + id integer primary key check (id = 1), + origin text not null, + token_ciphertext blob not null, + token_iv blob not null, + key_fingerprint text not null, + table_ids_json text not null default '[]', + include_site integer not null default 1, + created_by_user_id text references users(id) on delete set null, + created_at text not null default current_timestamp, + updated_at text not null default current_timestamp, + last_sync_at text, + last_sync_status text, + last_sync_error text, + constraint staging_environment_sync_status_check + check (last_sync_status is null or last_sync_status in ('success', 'failed')) + ); + `, + }, ] diff --git a/server/handlers/cms/import.ts b/server/handlers/cms/import.ts index 39137fb20..60fa1ee7d 100644 --- a/server/handlers/cms/import.ts +++ b/server/handlers/cms/import.ts @@ -61,7 +61,9 @@ import { SiteBundleSchema, ImportStrategySchema, ImportResultSchema, + type ImportResult, type ImportStrategy, + type SiteBundle, } from '@core/data/bundleSchema' import { CMS_API_PREFIX, type CmsHandlerOptions } from './shared' import { @@ -173,6 +175,16 @@ export async function handleImportRoute( } } + return jsonResponse(await applySiteBundle(db, bundle, strategy, options)) +} + +export async function applySiteBundle( + db: DbClient, + bundle: SiteBundle, + strategy: ImportStrategy | 'replace-selected', + options: CmsHandlerOptions = {}, +): Promise { + // --------------------------------------------------------------------------- // Counters // --------------------------------------------------------------------------- @@ -204,8 +216,11 @@ export async function handleImportRoute( ['pages', 'components', 'layouts'].map((tableId) => [tableId, new Set()]), ) let shellWasWritten = false - if (strategy === 'replace') { + if (strategy === 'replace' || strategy === 'replace-selected') { for (const tableId of affectedCollabRows.keys()) { + if (strategy === 'replace-selected' && !bundle.tables.some((table) => table.id === tableId)) { + continue + } for (const row of await listDataRows(db, tableId)) { affectedCollabRows.get(tableId)?.add(row.id) } @@ -305,6 +320,67 @@ export async function handleImportRoute( } } }) + } else if (strategy === 'replace-selected') { + await db.transaction(async (tx) => { + for (const table of bundle.tables) { + await tx`delete from data_rows where table_id = ${table.id}` + } + + const existingTableIds = new Set((await listDataTables(tx)).map((table) => table.id)) + for (const table of bundle.tables) { + if (existingTableIds.has(table.id)) { + await updateDataTable(tx, table.id, { + name: table.name, + slug: table.slug, + routeBase: table.routeBase, + singularLabel: table.singularLabel, + pluralLabel: table.pluralLabel, + primaryFieldId: table.primaryFieldId, + fields: table.fields, + }) + } else if (!SYSTEM_TABLE_IDS.has(table.id)) { + await createDataTable(tx, { + id: table.id, + name: table.name, + slug: table.slug, + kind: table.kind, + routeBase: table.routeBase, + singularLabel: table.singularLabel, + pluralLabel: table.pluralLabel, + primaryFieldId: table.primaryFieldId, + fields: table.fields, + }) + } + tablesAffected++ + } + + for (const row of bundle.rows) { + await replaceDataRow(tx, { + id: row.id, + tableId: row.tableId, + cells: row.cells, + slug: row.slug, + status: row.status, + publishedAt: row.publishedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }) + affectedCollabRows.get(row.tableId)?.add(row.id) + rowsInserted++ + } + + if (bundle.site) { + await saveDraftSite(tx, bundle.site, null, { collabInternal: true }) + shellWasWritten = true + } + + if (bundle.redirects) { + for (const redirect of bundle.redirects) { + await importDataRowRedirect(tx, redirect) + redirectsImported++ + } + } + }) } else if (strategy === 'merge-add') { // Add what's missing; never overwrite existing content. await db.transaction(async (tx) => { @@ -436,7 +512,9 @@ export async function handleImportRoute( if (ids.size > 0) notifyRowWrite({ tableId, rowIds: [...ids], kind: 'create' }) } } else { - const eventKind: RowWriteKind = strategy === 'replace' ? 'delete' : 'create' + const eventKind: RowWriteKind = strategy === 'replace' || strategy === 'replace-selected' + ? 'delete' + : 'create' for (const [tableId, ids] of affectedCollabRows) { if (ids.size > 0) notifyRowWrite({ tableId, rowIds: [...ids], kind: eventKind }) } @@ -518,5 +596,5 @@ export async function handleImportRoute( // Paranoia: validate result shape before returning parseValue(ImportResultSchema, result) - return jsonResponse(result) + return result } diff --git a/server/handlers/cms/index.ts b/server/handlers/cms/index.ts index 145868b4a..df823c50a 100644 --- a/server/handlers/cms/index.ts +++ b/server/handlers/cms/index.ts @@ -55,6 +55,7 @@ import { handleExportRoute } from './export' import { handleImportPreviewRoute } from './importPreview' import { handleImportArchiveRoute } from './importArchive' import { handleImportRoute } from './import' +import { handleStagingRoutes } from './staging' export type { CmsHandlerOptions } from './shared' @@ -111,6 +112,7 @@ export async function handleCmsRequest( ?? (await handleDashboardRoutes(req, db, options)) ?? (await handleFontsRoutes(req, db, options)) ?? (await handlePublishRoutes(req, db, options)) + ?? (await handleStagingRoutes(req, db)) // Export and import are registered after data routes so their exact paths // `/export` and `/import` cannot conflict with any `/data/...` sub-routes. // Preview must come before import: `/import/preview` is a longer path that diff --git a/server/handlers/cms/staging.ts b/server/handlers/cms/staging.ts new file mode 100644 index 000000000..05d081b63 --- /dev/null +++ b/server/handlers/cms/staging.ts @@ -0,0 +1,172 @@ +import type { DbClient } from '../../db/client' +import { requireCapability, requireStepUp } from '../../auth/authz' +import { badRequest, jsonResponse, methodNotAllowed, readValidatedBody } from '../../http' +import { listDataTables } from '../../repositories/data/tables' +import { createAuditEvent } from '../../repositories/audit' +import { + deleteStagingEnvironment, + getStagingEnvironment, + recordStagingSync, + resolveStagingEnvironment, + saveStagingEnvironment, + StagingEnvironmentError, +} from '../../repositories/stagingEnvironment' +import { buildStagingBundle } from '../../staging/bundle' +import { STAGING_SYNC_PATH } from '../../staging/receiver' +import { getErrorMessage } from '@core/utils/errorMessage' +import { parseJsonResponse } from '@core/utils/jsonValidate' +import { responseErrorMessage } from '@core/http' +import { + SaveStagingEnvironmentSchema, + StagingReceiverStatusSchema, + StagingRefreshResultSchema, +} from '@core/staging' +import { CMS_API_PREFIX, requestAuditContext } from './shared' + +const STAGING_PATH = `${CMS_API_PREFIX}/staging` +const STAGING_TEST_PATH = `${STAGING_PATH}/test` +const STAGING_REFRESH_PATH = `${STAGING_PATH}/refresh` + +export async function handleStagingRoutes(req: Request, db: DbClient): Promise { + const { pathname } = new URL(req.url) + if (![STAGING_PATH, STAGING_TEST_PATH, STAGING_REFRESH_PATH].includes(pathname)) return null + + const user = await requireCapability(req, db, 'deployment.manage') + if (user instanceof Response) return user + + try { + if (pathname === STAGING_PATH && req.method === 'GET') { + return jsonResponse(await getStagingEnvironment(db)) + } + + if (pathname === STAGING_PATH && req.method === 'PUT') { + const stepUp = await requireStepUp(req, db, user) + if (stepUp) return stepUp + const body = await readValidatedBody(req, SaveStagingEnvironmentSchema) + if (!body) return badRequest('Invalid staging configuration.') + + const origin = normalizeStagingOrigin(body.origin) + if (!origin) { + return badRequest('Use an HTTPS origin without a path, query, fragment, or embedded credentials.') + } + const knownTableIds = new Set((await listDataTables(db)).map((table) => table.id)) + const unknownTableId = body.tableIds.find((id) => !knownTableIds.has(id)) + if (unknownTableId) return badRequest(`Unknown data table: ${unknownTableId}`) + + const environment = await saveStagingEnvironment(db, { ...body, origin }, user.id) + await createAuditEvent(db, { + actorUserId: user.id, + action: 'staging.configured', + targetType: 'deployment_environment', + targetId: 'staging', + metadata: { origin, tableCount: body.tableIds.length, includeSite: body.includeSite }, + ...requestAuditContext(req), + }) + return jsonResponse(environment) + } + + if (pathname === STAGING_PATH && req.method === 'DELETE') { + const stepUp = await requireStepUp(req, db, user) + if (stepUp) return stepUp + const deleted = await deleteStagingEnvironment(db) + if (!deleted) return jsonResponse({ error: 'Staging is not configured.' }, { status: 404 }) + await createAuditEvent(db, { + actorUserId: user.id, + action: 'staging.removed', + targetType: 'deployment_environment', + targetId: 'staging', + ...requestAuditContext(req), + }) + return jsonResponse({ ok: true }) + } + + if (pathname === STAGING_TEST_PATH && req.method === 'POST') { + const target = await resolveStagingEnvironment(db) + await testTarget(target.origin, target.token) + return jsonResponse({ ok: true, origin: target.origin }) + } + + if (pathname === STAGING_REFRESH_PATH && req.method === 'POST') { + const stepUp = await requireStepUp(req, db, user) + if (stepUp) return stepUp + const target = await resolveStagingEnvironment(db) + try { + const bundle = await buildStagingBundle(db, target) + const response = await fetch(`${target.origin}${STAGING_SYNC_PATH}`, { + method: 'POST', + headers: { + authorization: `Bearer ${target.token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + mode: target.tableIds.length === 0 ? 'full' : 'selected', + bundle, + }), + signal: AbortSignal.timeout(120_000), + }) + if (!response.ok) { + throw new StagingEnvironmentError( + await responseErrorMessage(response, `Staging refresh failed with HTTP ${response.status}.`), + response.status, + ) + } + const result = await parseJsonResponse(response, StagingRefreshResultSchema) + await recordStagingSync(db, 'success', null) + await createAuditEvent(db, { + actorUserId: user.id, + action: 'staging.refreshed', + targetType: 'deployment_environment', + targetId: 'staging', + metadata: { + origin: target.origin, + mode: target.tableIds.length === 0 ? 'full' : 'selected', + tableCount: result.import.tablesAffected, + rowCount: result.import.rowsInserted, + publishedPages: result.publishedPages, + }, + ...requestAuditContext(req), + }) + return jsonResponse({ ...result, origin: target.origin }) + } catch (err) { + const message = getErrorMessage(err, 'Staging refresh failed.') + await recordStagingSync(db, 'failed', message.slice(0, 1000)) + throw err + } + } + + return methodNotAllowed() + } catch (err) { + if (err instanceof StagingEnvironmentError) { + return jsonResponse({ error: err.message }, { status: err.status }) + } + console.error('[staging] request failed:', err) + return jsonResponse({ error: 'Staging operation failed.' }, { status: 502 }) + } +} + +async function testTarget(origin: string, token: string): Promise { + const response = await fetch(`${origin}${STAGING_SYNC_PATH}`, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) { + throw new StagingEnvironmentError( + await responseErrorMessage(response, `Connection test failed with HTTP ${response.status}.`), + response.status, + ) + } + await parseJsonResponse(response, StagingReceiverStatusSchema) +} + +export function normalizeStagingOrigin(raw: string): string | null { + try { + const url = new URL(raw.trim()) + const localDevelopment = url.protocol === 'http:' + && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') + if (url.protocol !== 'https:' && !localDevelopment) return null + if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return null + return url.origin + } catch { + return null + } +} diff --git a/server/index.ts b/server/index.ts index 7d5b7579b..c8105e912 100644 --- a/server/index.ts +++ b/server/index.ts @@ -113,6 +113,8 @@ const server = Bun.serve({ staticDir: config.staticDir, uploadsDir: config.uploadsDir, databaseUrl: config.databaseUrl, + environment: config.environment, + stagingSyncToken: config.stagingSyncToken, }) for (const [k, v] of Object.entries(cors)) { res.headers.set(k, v) diff --git a/server/repositories/audit.ts b/server/repositories/audit.ts index f56a9a4b6..3e54873da 100644 --- a/server/repositories/audit.ts +++ b/server/repositories/audit.ts @@ -32,6 +32,9 @@ const AuditActionSchema = Type.Union([ Type.Literal('data.row.move'), Type.Literal('data.author.assign'), Type.Literal('publish'), + Type.Literal('staging.configured'), + Type.Literal('staging.removed'), + Type.Literal('staging.refreshed'), Type.Literal('plugin.install'), Type.Literal('plugin.update'), Type.Literal('plugin.enable'), diff --git a/server/repositories/stagingEnvironment.ts b/server/repositories/stagingEnvironment.ts new file mode 100644 index 000000000..168b7a1b7 --- /dev/null +++ b/server/repositories/stagingEnvironment.ts @@ -0,0 +1,193 @@ +import type { DbClient } from '../db/client' +import { decryptSecret, encryptSecret } from '../secrets/encryption' +import { + getMasterKeyFingerprint, + loadMasterKey, + MasterKeyConfigurationError, +} from '../secrets/masterKey' +import type { + SaveStagingEnvironment, + StagingEnvironment, + StagingSyncStatus, +} from '@core/staging' + +interface StagingEnvironmentRow { + origin: string + token_ciphertext: Uint8Array + token_iv: Uint8Array + key_fingerprint: string + table_ids_json: string[] + include_site: boolean | number + last_sync_at: string | null + last_sync_status: StagingSyncStatus | null + last_sync_error: string | null +} + +export interface ResolvedStagingEnvironment { + origin: string + token: string + tableIds: string[] + includeSite: boolean +} + +export class StagingEnvironmentError extends Error { + readonly status: number + + constructor(message: string, status = 400, options?: ErrorOptions) { + super(message, options) + this.name = 'StagingEnvironmentError' + this.status = status + } +} + +async function readRow(db: DbClient): Promise { + const { rows } = await db` + select origin, token_ciphertext, token_iv, key_fingerprint, table_ids_json, + include_site, last_sync_at, last_sync_status, last_sync_error + from staging_environment + where id = 1 + ` + return rows[0] ?? null +} + +export async function getStagingEnvironment(db: DbClient): Promise { + const row = await readRow(db) + if (!row) { + return { + configured: false, + origin: null, + hasToken: false, + keyFingerprintCurrent: true, + tableIds: [], + includeSite: true, + lastSyncAt: null, + lastSyncStatus: null, + lastSyncError: null, + } + } + + let fingerprint: string | null = null + try { + fingerprint = await getMasterKeyFingerprint() + } catch (err) { + console.error('[staging] master key unavailable while reading configuration:', err) + } + + return { + configured: true, + origin: row.origin, + hasToken: true, + keyFingerprintCurrent: row.key_fingerprint === fingerprint, + tableIds: row.table_ids_json, + includeSite: Boolean(row.include_site), + lastSyncAt: row.last_sync_at, + lastSyncStatus: row.last_sync_status, + lastSyncError: row.last_sync_error, + } +} + +export async function saveStagingEnvironment( + db: DbClient, + input: SaveStagingEnvironment, + userId: string, +): Promise { + const existing = await readRow(db) + let ciphertext = existing?.token_ciphertext + let iv = existing?.token_iv + let fingerprint = existing?.key_fingerprint + + if (input.token !== undefined) { + try { + const masterKey = await loadMasterKey() + const encrypted = await encryptSecret(masterKey, input.token) + ciphertext = encrypted.ciphertext + iv = encrypted.iv + fingerprint = await getMasterKeyFingerprint() + } catch (err) { + if (err instanceof MasterKeyConfigurationError) { + throw new StagingEnvironmentError( + `Staging token encryption is not configured: ${err.message.replace('[secrets/masterKey] ', '')}`, + 500, + { cause: err }, + ) + } + throw err + } + } + + if (!ciphertext || !iv || !fingerprint) { + throw new StagingEnvironmentError('A staging sync token is required for initial setup.') + } + + await db` + insert into staging_environment ( + id, origin, token_ciphertext, token_iv, key_fingerprint, + table_ids_json, include_site, created_by_user_id + ) values ( + 1, ${input.origin}, ${ciphertext}, ${iv}, ${fingerprint}, + ${input.tableIds}, ${input.includeSite}, ${userId} + ) + on conflict (id) do update + set origin = excluded.origin, + token_ciphertext = excluded.token_ciphertext, + token_iv = excluded.token_iv, + key_fingerprint = excluded.key_fingerprint, + table_ids_json = excluded.table_ids_json, + include_site = excluded.include_site, + updated_at = current_timestamp + ` + + return getStagingEnvironment(db) +} + +export async function resolveStagingEnvironment( + db: DbClient, +): Promise { + const row = await readRow(db) + if (!row) throw new StagingEnvironmentError('Staging is not configured.', 404) + + const fingerprint = await getMasterKeyFingerprint() + if (row.key_fingerprint !== fingerprint) { + throw new StagingEnvironmentError( + 'The staging token was encrypted with a different master key. Re-enter it before syncing.', + 409, + ) + } + + try { + const token = await decryptSecret(await loadMasterKey(), { + ciphertext: row.token_ciphertext, + iv: row.token_iv, + }) + return { + origin: row.origin, + token, + tableIds: row.table_ids_json, + includeSite: Boolean(row.include_site), + } + } catch (err) { + throw new StagingEnvironmentError('The staging token could not be decrypted. Re-enter it.', 409, { + cause: err, + }) + } +} + +export async function recordStagingSync( + db: DbClient, + status: StagingSyncStatus, + error: string | null, +): Promise { + await db` + update staging_environment + set last_sync_at = current_timestamp, + last_sync_status = ${status}, + last_sync_error = ${error}, + updated_at = current_timestamp + where id = 1 + ` +} + +export async function deleteStagingEnvironment(db: DbClient): Promise { + const result = await db`delete from staging_environment where id = 1` + return result.rowCount > 0 +} diff --git a/server/repositories/users.ts b/server/repositories/users.ts index dc6af42cc..ca49d725a 100644 --- a/server/repositories/users.ts +++ b/server/repositories/users.ts @@ -504,6 +504,19 @@ export async function countActiveOwners(db: DbClient): Promise { return Number(rows[0]?.count ?? 0) } +export async function findActiveOwnerUserId(db: DbClient): Promise { + const { rows } = await db<{ id: string }>` + select id + from users + where role_id = ${'owner'} + and status = ${'active'} + and deleted_at is null + order by created_at asc + limit 1 + ` + return rows[0]?.id ?? null +} + export async function markUserLoggedIn(db: DbClient, userId: string): Promise { await db` update users diff --git a/server/router.ts b/server/router.ts index 6b9ef0dfb..3c402fb5e 100644 --- a/server/router.ts +++ b/server/router.ts @@ -22,6 +22,7 @@ import { registry } from '@core/module-engine' import type { CssBundleFile, SiteCssBundleId } from '@core/publisher' import { buildPublishedSiteCssBundle } from './publish/siteCssBundle' import { mediaStorageRegistry } from '@core/plugins/mediaStorageRegistry' +import { handleStagingSyncRequest, STAGING_SYNC_PATH } from './staging/receiver' const VITE_DEV_URL = 'http://localhost:5173' @@ -35,6 +36,8 @@ interface ServerRuntime { * storage dashboard widget). */ databaseUrl?: string + environment?: 'production' | 'staging' + stagingSyncToken?: string } /** @@ -64,6 +67,7 @@ type RouteHandler = ( */ const routes: readonly RouteHandler[] = [ tryServeHealth, + tryServeStagingSync, // OAuth discovery, dynamic client registration, and token exchange for // hosted MCP clients. These endpoints are public protocol surfaces; the // interactive consent step remains admin-session + step-up gated. @@ -148,6 +152,20 @@ function tryServeHealth(_req: Request, _runtime: ServerRuntime, _url: URL, pathn return jsonResponse({ status: 'ok', ts: Date.now() }) } +function tryServeStagingSync( + req: Request, + runtime: ServerRuntime, + _url: URL, + pathname: string, +): Promise | null { + if (pathname !== STAGING_SYNC_PATH) return null + return handleStagingSyncRequest(req, runtime.db, { + environment: runtime.environment ?? 'production', + syncToken: runtime.stagingSyncToken, + uploadsDir: runtime.uploadsDir, + }) +} + function tryServeMcpOAuth( req: Request, runtime: ServerRuntime, diff --git a/server/staging/bundle.ts b/server/staging/bundle.ts new file mode 100644 index 000000000..35a45ffab --- /dev/null +++ b/server/staging/bundle.ts @@ -0,0 +1,33 @@ +import type { DbClient } from '../db/client' +import { getDraftSite } from '../repositories/site' +import { listDataTables } from '../repositories/data/tables' +import { listDataRows } from '../repositories/data/rows' +import { listExportableRedirects } from '../repositories/data/publish' +import type { SiteBundle } from '@core/data/bundleSchema' + +export async function buildStagingBundle( + db: DbClient, + input: { tableIds: readonly string[]; includeSite: boolean }, +): Promise { + const shell = await getDraftSite(db) + if (!shell) throw new Error('Site is not initialized.') + + const requested = input.tableIds.length > 0 ? new Set(input.tableIds) : null + const tables = (await listDataTables(db)).filter((table) => !requested || requested.has(table.id)) + const rows = (await Promise.all(tables.map((table) => listDataRows(db, table.id)))).flat() + const tableIds = new Set(tables.map((table) => table.id)) + const rowIds = new Set(rows.map((row) => row.id)) + const redirects = (await listExportableRedirects(db)).filter( + (redirect) => tableIds.has(redirect.tableId) && rowIds.has(redirect.targetRowId), + ) + + return { + schemaVersion: 1, + exportedAt: new Date().toISOString(), + sourceSiteName: shell.name, + ...(input.includeSite ? { site: shell } : {}), + tables, + rows, + redirects, + } +} diff --git a/server/staging/receiver.ts b/server/staging/receiver.ts new file mode 100644 index 000000000..aeba7b1f4 --- /dev/null +++ b/server/staging/receiver.ts @@ -0,0 +1,87 @@ +import { timingSafeEqual } from 'node:crypto' +import type { DbClient } from '../db/client' +import { + badRequest, + jsonResponse, + methodNotAllowed, + payloadTooLarge, + readValidatedBody, + RequestBodyTooLargeError, +} from '../http' +import { applySiteBundle } from '../handlers/cms/import' +import { findActiveOwnerUserId } from '../repositories/users' +import { publishDraftSite } from '../publish/publishSite' +import { + StagingSyncPayloadSchema, + type StagingRefreshResult, + type StagingSyncPayload, +} from '@core/staging' + +export const STAGING_SYNC_PATH = '/_instatic/staging-sync' +const MAX_SYNC_BYTES = 64 * 1024 * 1024 + +export interface StagingReceiverOptions { + environment: 'production' | 'staging' + syncToken?: string + uploadsDir?: string +} + +export async function handleStagingSyncRequest( + req: Request, + db: DbClient, + options: StagingReceiverOptions, +): Promise { + if (options.environment !== 'staging' || !options.syncToken) { + return jsonResponse({ error: 'Not found' }, { status: 404 }) + } + if (!validBearerToken(req, options.syncToken)) { + return jsonResponse({ error: 'Unauthorized' }, { status: 401 }) + } + + if (req.method === 'GET') { + return jsonResponse({ ok: true, environment: 'staging' }) + } + if (req.method !== 'POST') return methodNotAllowed() + + let payload: StagingSyncPayload | null + try { + payload = await readValidatedBody(req, StagingSyncPayloadSchema, { + maxBytes: MAX_SYNC_BYTES, + }) + } catch (err) { + if (err instanceof RequestBodyTooLargeError) { + return payloadTooLarge('Staging sync payload exceeds the 64 MiB limit.') + } + throw err + } + if (!payload) return badRequest('Invalid staging sync payload.') + + const ownerUserId = await findActiveOwnerUserId(db) + if (!ownerUserId) { + return jsonResponse({ error: 'The staging instance has no active owner.' }, { status: 409 }) + } + + const importResult = await applySiteBundle( + db, + payload.bundle, + payload.mode === 'full' ? 'replace' : 'replace-selected', + { uploadsDir: options.uploadsDir }, + ) + const publishResult = await publishDraftSite(db, ownerUserId, options.uploadsDir) + const result: StagingRefreshResult = { + ok: true, + origin: new URL(req.url).origin, + publishedPages: publishResult.publishedPages, + import: importResult, + } + return jsonResponse(result) +} + +function validBearerToken(req: Request, expected: string): boolean { + const header = req.headers.get('authorization') ?? '' + if (!header.startsWith('Bearer ')) return false + const actual = header.slice('Bearer '.length) + const actualBytes = Buffer.from(actual) + const expectedBytes = Buffer.from(expected) + return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes) +} diff --git a/src/__tests__/server/serverConfig.test.ts b/src/__tests__/server/serverConfig.test.ts index fb467f4c8..93728dc3d 100644 --- a/src/__tests__/server/serverConfig.test.ts +++ b/src/__tests__/server/serverConfig.test.ts @@ -126,6 +126,7 @@ describe('readServerConfig', () => { staticDir: './dist', trustedProxyCidrs: [], publicOrigins: [], + environment: 'production', }) }) @@ -148,6 +149,17 @@ describe('readServerConfig', () => { staticDir: '/srv/instatic/dist', trustedProxyCidrs: ['10.0.0.0/8', '192.168.0.0/16'], publicOrigins: ['https://cms.example.com', 'http://localhost:5173'], + environment: 'production', + }) + }) + + it('enables the staging receiver only from explicit environment settings', () => { + expect(readServerConfig({ + INSTATIC_ENVIRONMENT: 'staging', + STAGING_SYNC_TOKEN: ' long-random-token ', + })).toMatchObject({ + environment: 'staging', + stagingSyncToken: 'long-random-token', }) }) }) diff --git a/src/__tests__/server/stagingEnvironment.test.ts b/src/__tests__/server/stagingEnvironment.test.ts new file mode 100644 index 000000000..df6749f3b --- /dev/null +++ b/src/__tests__/server/stagingEnvironment.test.ts @@ -0,0 +1,151 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' +import { createSqliteClient } from '../../../server/db/sqlite' +import type { DbClient } from '../../../server/db/client' +import { runMigrations } from '../../../server/db/runMigrations' +import { sqliteMigrations } from '../../../server/db/migrations-sqlite' +import { syncSystemRoles } from '../../../server/repositories/roles' +import { handleCmsRequest } from '../../../server/handlers/cms' +import { + getStagingEnvironment, + resolveStagingEnvironment, + saveStagingEnvironment, +} from '../../../server/repositories/stagingEnvironment' +import { normalizeStagingOrigin } from '../../../server/handlers/cms/staging' +import { handleStagingSyncRequest } from '../../../server/staging/receiver' +import { __resetMasterKeyCacheForTesting } from '../../../server/secrets/masterKey' + +const TEST_MASTER_KEY = Buffer.alloc(32, 19).toString('base64') + +describe('staging environment', () => { + let db: DbClient + let ownerId: string + let ownerCookie: string + let originalSecretKey: string | undefined + + beforeAll(async () => { + originalSecretKey = process.env.INSTATIC_SECRET_KEY + process.env.INSTATIC_SECRET_KEY = TEST_MASTER_KEY + __resetMasterKeyCacheForTesting() + db = createSqliteClient(':memory:') + await runMigrations(db, sqliteMigrations) + await syncSystemRoles(db) + await cms('/admin/api/cms/setup', { + method: 'POST', + json: { + siteName: 'Staging test', + email: 'owner@staging.test', + password: 'long-enough-password', + }, + }) + const login = await cms('/admin/api/cms/login', { + method: 'POST', + json: { email: 'owner@staging.test', password: 'long-enough-password' }, + }) + ownerCookie = (login.headers.get('set-cookie') ?? '').split(';')[0]! + const { rows } = await db<{ id: string }>` + select id from users where role_id = ${'owner'} limit 1 + ` + ownerId = rows[0]!.id + }) + + afterAll(async () => { + await db.close() + if (originalSecretKey === undefined) delete process.env.INSTATIC_SECRET_KEY + else process.env.INSTATIC_SECRET_KEY = originalSecretKey + __resetMasterKeyCacheForTesting() + }) + + it('accepts HTTPS origins and loopback HTTP only', () => { + expect(normalizeStagingOrigin('https://Staging.Example.com/')).toBe('https://staging.example.com') + expect(normalizeStagingOrigin('http://localhost:3002')).toBe('http://localhost:3002') + expect(normalizeStagingOrigin('http://staging.example.com')).toBeNull() + expect(normalizeStagingOrigin('https://staging.example.com/admin')).toBeNull() + expect(normalizeStagingOrigin('https://user:pass@staging.example.com')).toBeNull() + }) + + it('encrypts the receiver token and exposes only wire-safe state', async () => { + await saveStagingEnvironment(db, { + origin: 'https://staging.example.com', + token: 'a-long-random-staging-token', + tableIds: ['posts'], + includeSite: true, + }, ownerId) + + const view = await getStagingEnvironment(db) + expect(view).toMatchObject({ + configured: true, + origin: 'https://staging.example.com', + hasToken: true, + keyFingerprintCurrent: true, + tableIds: ['posts'], + }) + expect(JSON.stringify(view)).not.toContain('a-long-random-staging-token') + + const { rows } = await db<{ token_ciphertext: Uint8Array }>` + select token_ciphertext from staging_environment where id = 1 + ` + expect(new TextDecoder().decode(rows[0]!.token_ciphertext)).not.toContain('a-long-random-staging-token') + expect((await resolveStagingEnvironment(db)).token).toBe('a-long-random-staging-token') + }) + + it('requires authentication and deployment.manage for the admin route', async () => { + expect((await cms('/admin/api/cms/staging')).status).toBe(401) + const response = await cms('/admin/api/cms/staging', { cookie: ownerCookie }) + expect(response.status).toBe(200) + }) + + it('keeps the receiver disabled on production and authenticates staging requests', async () => { + const production = await handleStagingSyncRequest( + new Request('https://staging.example.com/_instatic/staging-sync'), + db, + { environment: 'production', syncToken: 'receiver-token' }, + ) + expect(production.status).toBe(404) + + const unauthorized = await handleStagingSyncRequest( + new Request('https://staging.example.com/_instatic/staging-sync'), + db, + { environment: 'staging', syncToken: 'receiver-token' }, + ) + expect(unauthorized.status).toBe(401) + + const authorizedRequest = new Request('https://staging.example.com/_instatic/staging-sync') + authorizedRequest.headers.set('authorization', 'Bearer receiver-token') + const authorized = await handleStagingSyncRequest(authorizedRequest, db, { + environment: 'staging', + syncToken: 'receiver-token', + }) + expect(authorized.status).toBe(200) + expect(await authorized.json()).toEqual({ ok: true, environment: 'staging' }) + + const oversizedRequest = new Request('https://staging.example.com/_instatic/staging-sync', { + method: 'POST', + headers: { + authorization: 'Bearer receiver-token', + 'content-type': 'application/json', + }, + body: '{}', + }) + oversizedRequest.headers.set('content-length', String(64 * 1024 * 1024 + 1)) + const oversized = await handleStagingSyncRequest(oversizedRequest, db, { + environment: 'staging', + syncToken: 'receiver-token', + }) + expect(oversized.status).toBe(413) + }) + + function cms( + path: string, + options: { method?: string; cookie?: string; json?: unknown } = {}, + ): Promise { + const headers = new Headers() + if (options.json !== undefined) headers.set('content-type', 'application/json') + const request = new Request(`http://localhost${path}`, { + method: options.method, + headers, + body: options.json === undefined ? undefined : JSON.stringify(options.json), + }) + if (options.cookie) request.headers.set('cookie', options.cookie) + return handleCmsRequest(request, db) + } +}) diff --git a/src/__tests__/server/stagingSelectedImport.test.ts b/src/__tests__/server/stagingSelectedImport.test.ts new file mode 100644 index 000000000..da7b06b73 --- /dev/null +++ b/src/__tests__/server/stagingSelectedImport.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'bun:test' +import { createSqliteClient } from '../../../server/db/sqlite' +import { runMigrations } from '../../../server/db/runMigrations' +import { sqliteMigrations } from '../../../server/db/migrations-sqlite' +import { handleCmsRequest } from '../../../server/handlers/cms' +import { applySiteBundle } from '../../../server/handlers/cms/import' +import { createDataRow, listDataRows } from '../../../server/repositories/data/rows' +import { listDataTables } from '../../../server/repositories/data/tables' + +describe('replace-selected import', () => { + it('replaces selected rows while preserving every unselected table', async () => { + const db = createSqliteClient(':memory:') + try { + await runMigrations(db, sqliteMigrations) + await handleCmsRequest(new Request('http://localhost/admin/api/cms/setup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + siteName: 'Selected import test', + email: 'owner@selected-import.test', + password: 'long-enough-password', + }), + }), db) + const oldPost = await createDataRow(db, { + tableId: 'posts', + cells: { title: 'Old post', slug: 'old-post' }, + slug: 'old-post', + }) + const page = await createDataRow(db, { + tableId: 'pages', + cells: { title: 'Kept page', slug: 'kept-page' }, + slug: 'kept-page', + }) + const postsTable = (await listDataTables(db)).find((table) => table.id === 'posts')! + const now = new Date().toISOString() + + const result = await applySiteBundle(db, { + schemaVersion: 1, + exportedAt: now, + tables: [postsTable], + rows: [{ + id: 'staged-post', + tableId: 'posts', + cells: { title: 'Staged post', slug: 'staged-post' }, + slug: 'staged-post', + status: 'draft', + authorUserId: null, + createdByUserId: null, + updatedByUserId: null, + publishedByUserId: null, + author: null, + createdBy: null, + updatedBy: null, + publishedBy: null, + createdAt: now, + updatedAt: now, + publishedAt: null, + scheduledPublishAt: null, + deletedAt: null, + }], + }, 'replace-selected') + + expect(result.strategy).toBe('replace-selected') + expect((await listDataRows(db, 'posts')).map((row) => row.id)).toEqual(['staged-post']) + expect((await listDataRows(db, 'pages')).map((row) => row.id)).toContain(page.id) + expect((await listDataRows(db, 'posts')).map((row) => row.id)).not.toContain(oldPost.id) + } finally { + await db.close() + } + }) +}) diff --git a/src/__tests__/settings/settingsModal.test.tsx b/src/__tests__/settings/settingsModal.test.tsx index 8c4edd2d9..389a22f0b 100644 --- a/src/__tests__/settings/settingsModal.test.tsx +++ b/src/__tests__/settings/settingsModal.test.tsx @@ -193,12 +193,12 @@ describe('SettingsModal — backdrop', () => { // --------------------------------------------------------------------------- describe('SettingsModal — section navigation', () => { - it('renders exactly 4 nav items (general, shortcuts, publishing, preferences)', () => { + it('renders the five settings sections for an unrestricted session', () => { openModal() render() const nav = screen.getByRole('navigation', { name: /settings sections/i }) const navBtns = Array.from(nav.querySelectorAll('button')) - expect(navBtns.length).toBe(4) + expect(navBtns.length).toBe(5) }) it('renders nav items with the current section labels', () => { @@ -210,6 +210,7 @@ describe('SettingsModal — section navigation', () => { expect(within(nav).getByText('General')).toBeDefined() expect(within(nav).getByText('Shortcuts')).toBeDefined() expect(within(nav).getByText('Publishing')).toBeDefined() + expect(within(nav).getByText('Staging')).toBeDefined() expect(within(nav).getByText('Preferences')).toBeDefined() // Dropped sections — moved to their dedicated controls. expect(within(nav).queryByText('Pages')).toBeNull() @@ -575,7 +576,7 @@ describe('SettingsButton + settingsSlice — section ID alignment (source enforc }) it('settingsSlice activeSection default is a valid section ID', () => { - expect(settingsSliceSrc).toMatch(/DEFAULT_SECTION: SettingsSection = '(general|preferences|shortcuts|publishing)'/) + expect(settingsSliceSrc).toMatch(/DEFAULT_SECTION: SettingsSection = '(general|preferences|shortcuts|publishing|staging)'/) }) }) diff --git a/src/admin/modals/Settings/SettingsModal.module.css b/src/admin/modals/Settings/SettingsModal.module.css index e742e38e9..033a447d8 100644 --- a/src/admin/modals/Settings/SettingsModal.module.css +++ b/src/admin/modals/Settings/SettingsModal.module.css @@ -329,6 +329,101 @@ font-family: var(--font-mono); } +/* StagingSection */ +.stagingFields { + display: grid; + gap: var(--space-s); +} + +.fieldLabel { + margin-top: var(--space-s); + color: var(--text-bright); + font-size: var(--text-s); + font-weight: 600; +} + +.stagingWarning { + margin: var(--space-xs) 0 0; + padding: var(--space-m) var(--space-l); + border-radius: var(--panel-radius); + color: var(--warning-text); + background: var(--warning-10); + font-size: var(--text-s); + line-height: 1.5; +} + +.stagingTableList { + display: grid; + gap: var(--space-px); + max-height: 210px; + margin: var(--space-l) 0 0; + padding: var(--space-px); + overflow: auto; + border: 0; + border-radius: var(--panel-radius); + background: var(--border); +} + +.stagingTableList legend { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} + +.stagingTableRow { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-m); + padding: var(--space-m) var(--space-l); + background: var(--bg-surface-2); + color: var(--text-bright); + font-size: var(--text-s); + cursor: pointer; +} + +.stagingTableRow:first-of-type { + border-radius: var(--panel-radius) var(--panel-radius) 0 0; +} + +.stagingTableRow:last-of-type { + border-radius: 0 0 var(--panel-radius) var(--panel-radius); +} + +.stagingTableRow small { + color: var(--text-subtle); + font-variant-numeric: tabular-nums; +} + +.stagingStatus { + margin: var(--space-2xl) 0 0; + padding: var(--space-m) var(--space-l); + border-radius: var(--panel-radius); + background: var(--bg-surface-2); + color: var(--text-muted); + font-size: var(--text-s); + line-height: 1.5; +} + +.stagingStatus[data-status="success"] { + background: var(--success-10); + color: var(--success-text); +} + +.stagingStatus[data-status="failed"] { + background: var(--danger-10); + color: var(--danger-text); +} + +.stagingActions { + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + margin-top: var(--space-2xl); +} + /* ── GeneralSection ───────────────────────────────────────────────────────── */ .genFieldRow { margin-bottom: var(--space-2xl); diff --git a/src/admin/modals/Settings/SettingsModal.tsx b/src/admin/modals/Settings/SettingsModal.tsx index e5cfabd13..b3126eb00 100644 --- a/src/admin/modals/Settings/SettingsModal.tsx +++ b/src/admin/modals/Settings/SettingsModal.tsx @@ -26,10 +26,14 @@ import { SettingsCogSolidIcon } from 'pixel-art-icons/icons/settings-cog-solid' import { CommandIcon } from 'pixel-art-icons/icons/command' import { UploadIcon } from 'pixel-art-icons/icons/upload' import { SlidersHorizontalIcon } from 'pixel-art-icons/icons/sliders-horizontal' +import { DatabaseSolidIcon } from 'pixel-art-icons/icons/database-solid' +import { useCurrentAdminUser } from '@admin/sessionContext' +import { hasCapability } from '@admin/access' import { GeneralSection } from './sections/GeneralSection' import { PublishingSection } from './sections/PublishingSection' import { ShortcutsSection } from './sections/ShortcutsSection' import { PreferencesSection } from './sections/PreferencesSection' +import { StagingSection } from './sections/StagingSection' import s from './SettingsModal.module.css' // ─── Nav items ──────────────────────────────────────────────────────────────── @@ -41,6 +45,7 @@ const NAV_ITEMS = [ { id: 'general', label: 'General', icon: SettingsCogSolidIcon, accent: 'lilac' }, { id: 'shortcuts', label: 'Shortcuts', icon: CommandIcon, accent: 'sky' }, { id: 'publishing', label: 'Publishing', icon: UploadIcon, accent: 'mint' }, + { id: 'staging', label: 'Staging', icon: DatabaseSolidIcon, accent: 'sky' }, { id: 'preferences', label: 'Preferences', icon: SlidersHorizontalIcon, accent: 'peach' }, ] as const @@ -63,8 +68,13 @@ export function SettingsModal() { // modal is lazy-loaded — this editor-store import only fires when the // user actually opens settings, never on first paint. const setSectionStore = useEditorStore((state) => state.setSettingsSection) + const currentUser = useCurrentAdminUser() + const canManageDeployment = !currentUser || hasCapability(currentUser, 'deployment.manage') + const visibleNavItems = canManageDeployment + ? NAV_ITEMS + : NAV_ITEMS.filter((item) => item.id !== 'staging') - const activeSection = normalizeSection(adminUiSection) + const activeSection = normalizeSection(adminUiSection, canManageDeployment) const activeItem = NAV_ITEMS.find((n) => n.id === activeSection) ?? NAV_ITEMS[0] const dialogRef = useRef(null) const navRef = useRef(null) @@ -218,7 +228,7 @@ export function SettingsModal() { aria-label="Settings sections" className={s.sectionList} > - {NAV_ITEMS.map((item) => ( + {visibleNavItems.map((item) => ( } {activeSection === 'shortcuts' && } {activeSection === 'publishing' && } + {activeSection === 'staging' && } {activeSection === 'preferences' && } @@ -262,7 +273,11 @@ export function SettingsModal() { ) } -function normalizeSection(section: string | null | undefined): SectionId { +function normalizeSection( + section: string | null | undefined, + canManageDeployment: boolean, +): SectionId { + if (section === 'staging' && !canManageDeployment) return 'general' return NAV_ITEMS.some((item) => item.id === section) ? (section as SectionId) : 'general' } diff --git a/src/admin/modals/Settings/sections/StagingSection.tsx b/src/admin/modals/Settings/sections/StagingSection.tsx new file mode 100644 index 000000000..5136c3a8c --- /dev/null +++ b/src/admin/modals/Settings/sections/StagingSection.tsx @@ -0,0 +1,268 @@ +import { useState } from 'react' +import { useAsyncResource } from '@admin/lib/useAsyncResource' +import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { + deleteCmsStagingEnvironment, + getCmsStagingEnvironment, + refreshCmsStagingEnvironment, + saveCmsStagingEnvironment, + testCmsStagingEnvironment, +} from '@core/persistence' +import { listCmsDataTables } from '@core/persistence/cmsData' +import type { DataTableListItem } from '@core/data/schemas' +import type { StagingEnvironment } from '@core/staging' +import { getErrorMessage } from '@core/utils/errorMessage' +import { Button } from '@ui/components/Button' +import { Checkbox } from '@ui/components/Checkbox' +import { Input } from '@ui/components/Input' +import { SkeletonBlock } from '@ui/components/Skeleton' +import { Switch } from '@ui/components/Switch' +import { pushToast } from '@ui/components/Toast' +import s from '../SettingsModal.module.css' + +interface StagingResource { + environment: StagingEnvironment + tables: DataTableListItem[] +} + +export function StagingSection() { + const resource = useAsyncResource( + async (signal) => { + const [environment, tables] = await Promise.all([ + getCmsStagingEnvironment(signal), + listCmsDataTables(), + ]) + return { environment, tables } + }, + [], + { fallbackError: 'Failed to load staging settings' }, + ) + + if (resource.loading && !resource.data) { + return + } + if (resource.error || !resource.data) { + return

{resource.error}

+ } + + const environmentKey = [ + resource.data.environment.origin, + resource.data.environment.lastSyncAt, + ].join(':') + + return ( + + ) +} + +function StagingForm({ + initial, + tables, + onSaved, +}: { + initial: StagingEnvironment + tables: DataTableListItem[] + onSaved: () => void +}) { + const { runStepUp } = useStepUp() + const [origin, setOrigin] = useState(initial.origin ?? '') + const [token, setToken] = useState('') + const [includeSite, setIncludeSite] = useState(initial.includeSite) + const [syncAll, setSyncAll] = useState(initial.tableIds.length === 0) + const [selectedTableIds, setSelectedTableIds] = useState(() => new Set(initial.tableIds)) + const [busy, setBusy] = useState<'save' | 'test' | 'refresh' | 'delete' | null>(null) + + async function handleSave() { + if (!syncAll && selectedTableIds.size === 0) { + pushToast({ kind: 'error', title: 'Choose at least one table' }) + return + } + setBusy('save') + try { + await runStepUp(() => saveCmsStagingEnvironment({ + origin, + ...(token ? { token } : {}), + tableIds: syncAll ? [] : [...selectedTableIds], + includeSite, + })) + setToken('') + pushToast({ kind: 'success', title: 'Staging configuration saved' }) + onSaved() + } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return + pushToast({ + kind: 'error', + title: 'Could not save staging configuration', + body: getErrorMessage(err, 'Unknown staging configuration error'), + }) + } finally { + setBusy(null) + } + } + + async function handleTest() { + setBusy('test') + try { + await testCmsStagingEnvironment() + pushToast({ kind: 'success', title: 'Staging connection verified' }) + } catch (err) { + pushToast({ + kind: 'error', + title: 'Staging connection failed', + body: getErrorMessage(err, 'Unknown connection error'), + }) + } finally { + setBusy(null) + } + } + + async function handleRefresh() { + setBusy('refresh') + try { + const result = await runStepUp(refreshCmsStagingEnvironment) + pushToast({ + kind: 'success', + title: 'Staging refreshed', + body: `${result.import.rowsInserted} rows synchronized and ${result.publishedPages} pages published.`, + }) + onSaved() + } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return + pushToast({ + kind: 'error', + title: 'Staging refresh failed', + body: getErrorMessage(err, 'Unknown staging refresh error'), + }) + onSaved() + } finally { + setBusy(null) + } + } + + async function handleDelete() { + setBusy('delete') + try { + await runStepUp(deleteCmsStagingEnvironment) + pushToast({ kind: 'success', title: 'Staging environment disconnected' }) + onSaved() + } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return + pushToast({ + kind: 'error', + title: 'Could not disconnect staging', + body: getErrorMessage(err, 'Unknown staging configuration error'), + }) + } finally { + setBusy(null) + } + } + + function toggleTable(tableId: string, checked: boolean) { + setSelectedTableIds((current) => { + const next = new Set(current) + if (checked) next.add(tableId) + else next.delete(tableId) + return next + }) + } + + return ( +
+

+ Connect a separate Instatic instance and refresh its database without affecting production. +

+ +
+

Target

+
+ + setOrigin(event.currentTarget.value)} + /> + + setToken(event.currentTarget.value)} + /> + {!initial.keyFingerprintCurrent && ( +

+ The server encryption key changed. Re-enter the sync token before continuing. +

+ )} +
+
+ +
+

Refresh scope

+
+
+
+ +

Include pages, breakpoints, classes, files, and runtime settings.

+
+ +
+
+
+ +

Replace the complete staging content database on every refresh.

+
+ +
+
+ + {!syncAll && ( +
+ Select tables + {tables.map((table) => ( + + ))} +
+ )} +
+ + {initial.lastSyncAt && ( +

+ Last refresh: {new Date(initial.lastSyncAt).toLocaleString()} + {initial.lastSyncError ? ` - ${initial.lastSyncError}` : ''} +

+ )} + +
+ + + + {initial.configured && ( + + )} +
+
+ ) +} diff --git a/src/admin/modals/SiteImport/shared/useCmsBundleImport.ts b/src/admin/modals/SiteImport/shared/useCmsBundleImport.ts index 680ab85a0..67e70d0ea 100644 --- a/src/admin/modals/SiteImport/shared/useCmsBundleImport.ts +++ b/src/admin/modals/SiteImport/shared/useCmsBundleImport.ts @@ -53,8 +53,9 @@ function pluralize(count: number, singular: string, plural: string): string { } function buildCmsImportToastBody(result: CmsImportResult): string { - const strategyLabel: Record = { + const strategyLabel: Record = { replace: 'Replace', + 'replace-selected': 'Replace selected', 'merge-add': 'Merge-add', 'merge-overwrite': 'Merge-overwrite', } diff --git a/src/admin/pages/dashboard/widgets/ActivityWidget.tsx b/src/admin/pages/dashboard/widgets/ActivityWidget.tsx index e486b112e..72be75f5a 100644 --- a/src/admin/pages/dashboard/widgets/ActivityWidget.tsx +++ b/src/admin/pages/dashboard/widgets/ActivityWidget.tsx @@ -68,6 +68,12 @@ function actionVerb(action: string): string { return 'deleted collection' case 'publish': return 'published the site' + case 'staging.configured': + return 'configured staging' + case 'staging.removed': + return 'removed staging' + case 'staging.refreshed': + return 'refreshed staging' case 'plugin.install': return 'installed plugin' case 'plugin.update': diff --git a/src/admin/pages/site/store/slices/settingsSlice.ts b/src/admin/pages/site/store/slices/settingsSlice.ts index 00f0f9ba2..490efff46 100644 --- a/src/admin/pages/site/store/slices/settingsSlice.ts +++ b/src/admin/pages/site/store/slices/settingsSlice.ts @@ -32,6 +32,7 @@ export type SettingsSection = | 'preferences' | 'shortcuts' | 'publishing' + | 'staging' export interface SettingsSlice { /** Whether the settings modal is currently open */ diff --git a/src/admin/pages/users/utils/audit.ts b/src/admin/pages/users/utils/audit.ts index cc6a3c9ba..0f5f1b6de 100644 --- a/src/admin/pages/users/utils/audit.ts +++ b/src/admin/pages/users/utils/audit.ts @@ -153,6 +153,12 @@ export function auditTitle( return `Data row ${dataRow} author changed` case 'publish': return 'Site was published' + case 'staging.configured': + return 'Staging environment was configured' + case 'staging.removed': + return 'Staging environment was removed' + case 'staging.refreshed': + return 'Staging environment was refreshed' case 'plugin.install': return `${pluginId} was installed` case 'plugin.update': diff --git a/src/admin/pages/users/utils/capabilities.ts b/src/admin/pages/users/utils/capabilities.ts index 2cc318e13..3a82ac04f 100644 --- a/src/admin/pages/users/utils/capabilities.ts +++ b/src/admin/pages/users/utils/capabilities.ts @@ -56,7 +56,12 @@ export const CAPABILITY_GROUPS: CapabilityGroup[] = [ }, { title: 'Runtime & storage', - capabilities: ['runtime.dependencies', 'storage.elect', 'storage.migrate'], + capabilities: [ + 'runtime.dependencies', + 'storage.elect', + 'storage.migrate', + 'deployment.manage', + ], }, { title: 'Plugins', diff --git a/src/admin/shared/CapabilityPicker/capabilityMeta.ts b/src/admin/shared/CapabilityPicker/capabilityMeta.ts index f8ee5d231..848c868e7 100644 --- a/src/admin/shared/CapabilityPicker/capabilityMeta.ts +++ b/src/admin/shared/CapabilityPicker/capabilityMeta.ts @@ -103,6 +103,10 @@ export const CAPABILITY_META: Record = { label: 'Migrate storage bytes', description: 'Run the migration SSE that moves bytes between storage adapters after an election change.', }, + 'deployment.manage': { + label: 'Manage deployment environments', + description: 'Configure staging targets, test their connection, and replace staging content from this instance.', + }, // --------------------------------------------------------------------- // Plugins — granular split (read/configure/install/lifecycle) // --------------------------------------------------------------------- diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index cc0060156..0a91ed2e1 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -66,6 +66,8 @@ export const CORE_CAPABILITIES = [ 'data.rows.move', 'data.export', 'data.import', + // Deployment targets and environment promotion. + 'deployment.manage', // AI runtime — `ai.chat` for conversations + read tools; `ai.tools.write` // for canvas write tools. See `docs/plans/2026-05-26-ai-runtime-rewrite.md`. 'ai.chat', diff --git a/src/core/data/bundleSchema.ts b/src/core/data/bundleSchema.ts index 9dbcfe98d..c893c281a 100644 --- a/src/core/data/bundleSchema.ts +++ b/src/core/data/bundleSchema.ts @@ -337,7 +337,7 @@ export type BundlePreview = Static */ export const ImportResultSchema = Type.Object({ ok: Type.Literal(true), - strategy: ImportStrategySchema, + strategy: Type.Union([ImportStrategySchema, Type.Literal('replace-selected')]), tablesAffected: Type.Number(), rowsInserted: Type.Number(), rowsReplaced: Type.Number(), diff --git a/src/core/persistence/cmsStaging.ts b/src/core/persistence/cmsStaging.ts new file mode 100644 index 000000000..87e6306ca --- /dev/null +++ b/src/core/persistence/cmsStaging.ts @@ -0,0 +1,57 @@ +import { Type } from '@core/utils/typeboxHelpers' +import { apiRequest } from '@core/http' +import { + StagingConnectionResultSchema, + StagingEnvironmentSchema, + StagingRefreshResultSchema, + type SaveStagingEnvironment, + type StagingEnvironment, + type StagingRefreshResult, +} from '@core/staging' + +const OkSchema = Type.Object({ ok: Type.Literal(true) }) +const STAGING_PATH = '/admin/api/cms/staging' + +export function getCmsStagingEnvironment(signal?: AbortSignal): Promise { + return apiRequest(STAGING_PATH, { + schema: StagingEnvironmentSchema, + signal, + fallbackMessage: 'Failed to load staging configuration', + }) +} + +export function saveCmsStagingEnvironment( + input: SaveStagingEnvironment, +): Promise { + return apiRequest(STAGING_PATH, { + method: 'PUT', + body: input, + schema: StagingEnvironmentSchema, + fallbackMessage: 'Failed to save staging configuration', + }) +} + +export async function deleteCmsStagingEnvironment(): Promise { + await apiRequest(STAGING_PATH, { + method: 'DELETE', + schema: OkSchema, + fallbackMessage: 'Failed to remove staging configuration', + }) +} + +export async function testCmsStagingEnvironment(): Promise { + const result = await apiRequest(`${STAGING_PATH}/test`, { + method: 'POST', + schema: StagingConnectionResultSchema, + fallbackMessage: 'Staging connection test failed', + }) + return result.origin +} + +export function refreshCmsStagingEnvironment(): Promise { + return apiRequest(`${STAGING_PATH}/refresh`, { + method: 'POST', + schema: StagingRefreshResultSchema, + fallbackMessage: 'Staging refresh failed', + }) +} diff --git a/src/core/persistence/index.ts b/src/core/persistence/index.ts index 27ae9be94..87082b969 100644 --- a/src/core/persistence/index.ts +++ b/src/core/persistence/index.ts @@ -85,6 +85,13 @@ export { updateCurrentUserProfile, uploadCurrentUserAvatar, } from './cmsAuth' +export { + deleteCmsStagingEnvironment, + getCmsStagingEnvironment, + refreshCmsStagingEnvironment, + saveCmsStagingEnvironment, + testCmsStagingEnvironment, +} from './cmsStaging' export type { CmsCurrentUser, CmsLoginActivityEvent, diff --git a/src/core/staging/index.ts b/src/core/staging/index.ts new file mode 100644 index 000000000..36cbd53d5 --- /dev/null +++ b/src/core/staging/index.ts @@ -0,0 +1,14 @@ +export { + SaveStagingEnvironmentSchema, + StagingConnectionResultSchema, + StagingEnvironmentSchema, + StagingRefreshResultSchema, + StagingReceiverStatusSchema, + StagingSyncPayloadSchema, + StagingSyncStatusSchema, + type SaveStagingEnvironment, + type StagingEnvironment, + type StagingRefreshResult, + type StagingSyncStatus, + type StagingSyncPayload, +} from './schemas' diff --git a/src/core/staging/schemas.ts b/src/core/staging/schemas.ts new file mode 100644 index 000000000..22ba1f14b --- /dev/null +++ b/src/core/staging/schemas.ts @@ -0,0 +1,58 @@ +import { Type, type Static } from '@core/utils/typeboxHelpers' +import { ImportResultSchema, SiteBundleSchema } from '@core/data/bundleSchema' + +export const StagingSyncStatusSchema = Type.Union([ + Type.Literal('success'), + Type.Literal('failed'), +]) + +export type StagingSyncStatus = Static + +export const StagingEnvironmentSchema = Type.Object({ + configured: Type.Boolean(), + origin: Type.Union([Type.String(), Type.Null()]), + hasToken: Type.Boolean(), + keyFingerprintCurrent: Type.Boolean(), + tableIds: Type.Array(Type.String()), + includeSite: Type.Boolean(), + lastSyncAt: Type.Union([Type.String(), Type.Null()]), + lastSyncStatus: Type.Union([StagingSyncStatusSchema, Type.Null()]), + lastSyncError: Type.Union([Type.String(), Type.Null()]), +}) + +export type StagingEnvironment = Static + +export const SaveStagingEnvironmentSchema = Type.Object({ + origin: Type.String({ minLength: 1, maxLength: 2048 }), + token: Type.Optional(Type.String({ minLength: 16, maxLength: 4096 })), + tableIds: Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }), + includeSite: Type.Boolean(), +}) + +export type SaveStagingEnvironment = Static + +export const StagingConnectionResultSchema = Type.Object({ + ok: Type.Literal(true), + origin: Type.String(), +}) + +export const StagingReceiverStatusSchema = Type.Object({ + ok: Type.Literal(true), + environment: Type.Literal('staging'), +}) + +export const StagingSyncPayloadSchema = Type.Object({ + mode: Type.Union([Type.Literal('full'), Type.Literal('selected')]), + bundle: SiteBundleSchema, +}) + +export type StagingSyncPayload = Static + +export const StagingRefreshResultSchema = Type.Object({ + ok: Type.Literal(true), + origin: Type.String(), + publishedPages: Type.Number(), + import: ImportResultSchema, +}) + +export type StagingRefreshResult = Static