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
17 changes: 15 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,22 @@ UPLOADS_DIR=./uploads
STATIC_DIR=./dist

# ─── AI credential encryption ───────────────────────────────────────────────
# Local dev auto-creates .tmp/secret.key. Production deployments must set
# INSTATIC_SECRET_KEY to the output of:
# Master encryption key (AES-256-GCM, 32 bytes, base64 or hex).
#
# Priority for the dev server:
# 1. INSTATIC_SECRET_KEY=... (base64 or hex, 32 bytes after decoding)
# 2. INSTATIC_SECRET_KEY_FILE=/abs/path/to/secret.key
# 3. INSTATIC_ALLOW_DEV_KEY_AUTOGEN=1
# auto-generates a key at the platform-native per-user config dir:
# Linux: $XDG_CONFIG_HOME/instatic/secret.key (default ~/.config/instatic/secret.key)
# macOS: ~/Library/Application Support/instatic/secret.key
# Windows: %APPDATA%\instatic\secret.key
# If a key already exists at that path, the existing key is reused.
#
# Production deployments must set INSTATIC_SECRET_KEY to the output of:
#
# bun run scripts/generate-secret-key.ts
#
# INSTATIC_SECRET_KEY=
# INSTATIC_SECRET_KEY_FILE=/abs/path/to/secret.key
# INSTATIC_ALLOW_DEV_KEY_AUTOGEN=0
13 changes: 13 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,22 @@ INSTATIC_IMAGE=ghcr.io/corebunch/instatic:latest
# ─── Reversible server secret encryption ───────────────────────────────────
# REQUIRED before adding Anthropic/OpenAI/OpenRouter credentials, saving plugin
# secret settings, or enabling TOTP MFA in production.
# The key is 32 bytes of random data, encoded as base64 or hex.
# Generate with:
# bun run scripts/generate-secret-key.ts
#
# Alternative inputs (non-production only):
# INSTATIC_SECRET_KEY_FILE=/abs/path/to/secret.key
# reads a base64- or hex-encoded 32-byte key from this file
# INSTATIC_ALLOW_DEV_KEY_AUTOGEN=1
# auto-generates a key at the platform-native per-user config dir and reuses
# it on later boots:
# Linux: $XDG_CONFIG_HOME/instatic/secret.key (default ~/.config/instatic/secret.key)
# macOS: ~/Library/Application Support/instatic/secret.key
# Windows: %APPDATA%\instatic\secret.key
INSTATIC_SECRET_KEY=replace-with-output-of-generate-secret-key
# INSTATIC_SECRET_KEY_FILE=/abs/path/to/secret.key
# INSTATIC_ALLOW_DEV_KEY_AUTOGEN=0

# ─── Networking ──────────────────────────────────────────────────────────────
# HOST_PORT is the port the app is exposed on directly (no TLS).
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ dist-ssr
.claude/
.tmp/
.tmp-lint/
# Master encryption key — auto-generated for local dev when
# INSTATIC_ALLOW_DEV_KEY_AUTOGEN=1 is set. Written to
# ~/.config/instatic/secret.key (or platform equivalent), not to the source tree.
.tmp/secret.key
.coverage/
coverage/
.matrix/
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"dev:vite": "bun run scripts/vite.ts",
"e2e:dev": "bun run scripts/e2e-dev.ts",
"db:drop": "bun run scripts/db-drop.ts",
"security:rotate-mcp": "bun run scripts/rotate-mcp-token.ts",
"start": "bun run scripts/start.ts",
"docker:up": "docker compose -f compose.prod.yml -f compose.build.yml up --build",
"release:bundle": "bun run scripts/build-release-bundle.ts",
Expand Down
26 changes: 26 additions & 0 deletions scripts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@
* and signals so Ctrl+C cleanly kills both.
*/

import { existsSync } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
import { isSqliteUrl } from '../server/db'
import { defaultMasterKeyPath } from '../server/secrets/paths'
import { bunCommand, viteCommand } from './lib/bunCommand'
import { ensurePortFree } from './lib/freePort'

Expand Down Expand Up @@ -207,6 +209,30 @@ async function waitForPostgresReady(timeoutMs = 60_000): Promise<void> {

// --- main -----------------------------------------------------------------

// Fail-closed master-key guard. We don't read the key here (the server does that
// on first encrypt), but we DO want to short-circuit before spawning docker or
// the cms child if there's no way the server can boot. A key path that already
// exists is enough — the master-key module re-uses it without rewriting.
function hasUsableMasterKey(): boolean {
if (process.env.INSTATIC_SECRET_KEY?.trim()) return true
if (process.env.INSTATIC_SECRET_KEY_FILE?.trim()) return true
if (process.env.INSTATIC_ALLOW_DEV_KEY_AUTOGEN === '1') {
try {
if (existsSync(defaultMasterKeyPath())) return true
} catch {
// best-effort
}
}
return false
}

if (!hasUsableMasterKey()) {
fail(
'set INSTATIC_SECRET_KEY, INSTATIC_SECRET_KEY_FILE, or ' +
'INSTATIC_ALLOW_DEV_KEY_AUTOGEN=1 to start the dev server.',
)
}

if (isSqliteUrl(DATABASE_URL)) {
const dbPath = DATABASE_URL.replace(/^sqlite:|^file:/, '')
await mkdir(dirname(dbPath), { recursive: true })
Expand Down
164 changes: 164 additions & 0 deletions scripts/rotate-mcp-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env bun
/**
* Rotate the local development MCP connector token without printing either the
* old or new bearer secret.
*
* The admin endpoint deliberately returns a connector token only once. This
* helper uses the same generator and repository path for operators who need to
* rotate a local token after it has been exposed, while keeping the operation
* scoped to the SQLite development database and the ignored `.env` file.
*
* Usage:
*
* bun run scripts/rotate-mcp-token.ts
*
* The current `INSTATIC_MCP_TOKEN` is read from `.env`. The replacement is
* written there with mode 0600. The old database row is deleted after the
* replacement row is inserted, so its hash cannot remain usable or linger in
* a revoked row.
*/

import { chmod, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createConnector, findConnectorByTokenHash } from '../server/ai/mcp/connectors/store'
import { generateConnectorToken, hashConnectorToken } from '../server/ai/mcp/connectors/token'
import { createDbClient, isSqliteUrl } from '../server/db'
import { readServerConfig } from '../server/config'

const ENV_FILE = join(import.meta.dir, '..', '.env')
const PRIVATE_FILE_MODE = 0o600
const TOKEN_ASSIGNMENT_RE = /^[ \t]*(?:export[ \t]+)?INSTATIC_MCP_TOKEN[ \t]*=/

function fail(message: string): never {
throw new Error(`[rotate-mcp-token] ${message}`)
}

function readToken(envText: string): string {
const match = envText.match(/^[ \t]*(?:export[ \t]+)?INSTATIC_MCP_TOKEN[ \t]*=[ \t]*([^\r\n]*)$/m)
const raw = match?.[1]?.trim()
if (!raw) fail(`INSTATIC_MCP_TOKEN is missing from ${ENV_FILE}`)

if (
raw.length >= 2 &&
((raw.startsWith('"') && raw.endsWith('"')) ||
(raw.startsWith("'") && raw.endsWith("'")))
) {
return raw.slice(1, -1)
}
return raw
}

function replaceToken(envText: string, token: string): string {
const lineEnding = envText.includes('\r\n') ? '\r\n' : '\n'
const lines = envText.split(/\r?\n/)
let replaced = false
const output: string[] = []

for (const line of lines) {
if (TOKEN_ASSIGNMENT_RE.test(line)) {
if (!replaced) {
output.push(`INSTATIC_MCP_TOKEN=${token}`)
replaced = true
}
continue
}
output.push(line)
}

if (!replaced) fail(`INSTATIC_MCP_TOKEN is missing from ${ENV_FILE}`)
return output.join(lineEnding)
}

async function writePrivateEnv(contents: string): Promise<void> {
const temporaryFile = `${ENV_FILE}.${crypto.randomUUID()}.tmp`
try {
await writeFile(temporaryFile, contents, { encoding: 'utf8', mode: PRIVATE_FILE_MODE })
await chmod(temporaryFile, PRIVATE_FILE_MODE)
await rename(temporaryFile, ENV_FILE)
await chmod(ENV_FILE, PRIVATE_FILE_MODE)
} finally {
await unlink(temporaryFile).catch(() => {})
}
}

const originalEnv = await readFile(ENV_FILE, 'utf8').catch(() => fail(`cannot read ${ENV_FILE}`))
const oldToken = readToken(originalEnv)
const config = readServerConfig()
if (!isSqliteUrl(config.databaseUrl)) {
fail('refusing to rotate a non-SQLite DATABASE_URL; this helper is local-development-only')
}

const oldHash = await hashConnectorToken(oldToken)
const newToken = generateConnectorToken()
const newHash = await hashConnectorToken(newToken)
const { db } = createDbClient(config.databaseUrl)
const oldConnector = await findConnectorByTokenHash(db, oldHash)
if (!oldConnector) fail('the token in .env does not resolve to an active MCP connector')

// Update the ignored local secret first. If the database transaction fails,
// restore the original file so a failed rotation does not strand the operator.
await writePrivateEnv(replaceToken(originalEnv, newToken))
let committed = false

try {
const replacement = await db.transaction(async (tx) => {
const created = await createConnector(tx, {
userId: oldConnector.userId,
label: `${oldConnector.label} (rotated)`,
type: oldConnector.type,
capabilities: oldConnector.capabilities,
tokenHash: newHash,
ttlDays: 90,
})

const deleted = await tx`
delete from ai_mcp_connectors
where id = ${oldConnector.id} and token_hash = ${oldHash}
`
if (deleted.rowCount !== 1) throw new Error('the old connector changed during rotation')

// Verify inside the transaction. This prevents a post-commit verification
// failure from restoring the old .env value after the old row is gone.
const [{ rows: newRows }, { rows: oldRows }] = await Promise.all([
tx`
select id from ai_mcp_connectors where token_hash = ${newHash} and revoked_at is null
`,
tx`select id from ai_mcp_connectors where token_hash = ${oldHash}`,
])
if (newRows.length !== 1 || oldRows.length !== 0) {
throw new Error('post-rotation hash verification failed')
}

return created
})
committed = true

console.log(`[rotate-mcp-token] rotated connector ${oldConnector.id} -> ${replacement.id}`)
console.log(`[rotate-mcp-token] replacement written to ${ENV_FILE} (mode 0600)`)
// The new bearer token is shown to the operator exactly once, mirroring the
// admin endpoint. Anyone rerunning this script will see a fresh value; the
// stored hash cannot be reversed into the plaintext.
console.log(`[rotate-mcp-token] new INSTATIC_MCP_TOKEN=${newToken}`)
} catch (error) {
// A transaction callback failure rolls back. Only restore the old file when
// the database still proves that rollback happened; never overwrite the new
// secret after an ambiguous database outcome.
if (!committed) {
try {
const [{ rows: newRows }, { rows: oldRows }] = await Promise.all([
db`
select id from ai_mcp_connectors where token_hash = ${newHash} and revoked_at is null
`,
db`select id from ai_mcp_connectors where token_hash = ${oldHash}`,
])
if (newRows.length === 0 && oldRows.length === 1) {
await writePrivateEnv(originalEnv)
} else {
console.error('[rotate-mcp-token] database outcome is ambiguous; leaving the replacement in .env')
}
} catch (restoreError) {
console.error('[rotate-mcp-token] could not verify rollback or restore .env:', restoreError)
}
}
throw error
}
Loading