Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
4 changes: 4 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion docs/deployment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/deployment/docker-image.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
43 changes: 43 additions & 0 deletions docs/deployment/staging.md
Original file line number Diff line number Diff line change
@@ -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=<at-least-32-random-bytes>
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.
10 changes: 9 additions & 1 deletion docs/features/site-transfer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions server/auth/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const adminCapabilities: CoreCapability[] = [
'data.rows.move',
'data.export',
'data.import',
'deployment.manage',
'ai.chat',
'ai.tools.write',
'ai.providers.manage',
Expand Down
7 changes: 7 additions & 0 deletions server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ interface ServerConfig {
staticDir: string
trustedProxyCidrs: string[]
publicOrigins: string[]
environment: 'production' | 'staging'
stagingSyncToken?: string
}

function readCsvList(value: string | undefined): string[] {
Expand Down Expand Up @@ -88,12 +90,17 @@ export function resolvePublicOrigins(env: Record<string, string | undefined>): s
export function readServerConfig(
env: Record<string, string | undefined> = 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',
uploadsDir: env.UPLOADS_DIR ?? './uploads',
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() }
: {}),
}
}
22 changes: 22 additions & 0 deletions server/db/migrations-pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
);
`,
},
]
22 changes: 22 additions & 0 deletions server/db/migrations-sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
);
`,
},
]
84 changes: 81 additions & 3 deletions server/handlers/cms/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ImportResult> {

// ---------------------------------------------------------------------------
// Counters
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -204,8 +216,11 @@ export async function handleImportRoute(
['pages', 'components', 'layouts'].map((tableId) => [tableId, new Set<string>()]),
)
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)
}
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 })
}
Expand Down Expand Up @@ -518,5 +596,5 @@ export async function handleImportRoute(
// Paranoia: validate result shape before returning
parseValue(ImportResultSchema, result)

return jsonResponse(result)
return result
}
2 changes: 2 additions & 0 deletions server/handlers/cms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down
Loading