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
707 changes: 707 additions & 0 deletions changes.patch

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions docs/content/docs/1.getting-started/3.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,26 @@ preview: {
}
```


## `encryption`

Nuxt Content v3 can optionally **encrypt** the prerendered content dumps so they can be hosted as public static assets (CDN, Cloudflare Pages) while remaining unreadable without a key. The browser fetches the encrypted dump, requests a short-lived key from your app (after authentication), decrypts locally, then hydrates the WASM SQLite database.

### `content.encryption`

```ts [nuxt.config.ts]
export default defineNuxtConfig({
content: {
encryption: {
enabled: true, // turn on encrypted dumps + key endpoint
masterKey: process.env.NUXT_CONTENT_MASTER_KEY, // base64(32 bytes)
}
}
})
```

If `masterKey` is omitted, a random 32-byte key is generated at build time and kept on the server.

## `experimental`

Experimental features that are not yet stable.
Expand Down
205 changes: 205 additions & 0 deletions docs/content/docs/8.advanced/9.private.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
---
title: Encrypted Dumps
description: Encrypted Dumps in Nuxt Content allow you to serve content safely on a public CDN while requiring authentication to access it.
---

Encrypted Dumps in Nuxt Content allow you to serve your content safely on a public CDN (e.g. Cloudflare Pages) without exposing the raw `.sql` database files.
Instead, dumps are encrypted at build time and decrypted in the browser only after the user has authenticated and received a short-lived key.

They are especially useful for:

- Hosting private content on static/CDN deployments
- Keeping v3’s fast client-side SQLite queries without leaking raw dumps
- Adding fine-grained access control using your own authentication

## How it works

1. **Build** – Each collection is compressed and encrypted with AES-256-GCM.
2. **Static hosting** – Only encrypted `.enc` files are published (`dump.<collection>.sql.enc`).
3. **Key request** – The client requests a short-lived key from `/__nuxt_content/:collection/key`, passing the `kid` extracted from the encrypted dump envelope.
4. **Decrypt & hydrate** – The browser decrypts the dump in memory and hydrates its WASM SQLite database.

Without the key, the dumps are useless.

## Static files produced

When `encryption.enabled = true`:

- βœ… `dump.<collection>.sql.enc` β†’ Encrypted database dump, safe to host on CDN.
- βœ… `database/queries/*.sql` β†’ Still generated internally, but not exposed publicly.
- ❌ No `.sql` or `.txt` raw dumps are emitted to `public/` or `_nuxt/`.

When `encryption.enabled = false` (default):

- Raw `.sql` or `.txt` dumps are emitted and directly fetched by the client (plain-text behavior).


## API endpoints

Nuxt Content automatically provides endpoints for both **encrypted** and **unencrypted** modes.

### 1. Encrypted mode

- `GET /__nuxt_content/:collection/sql_dump.enc`
Returns the encrypted dump envelope (stringified JSON, base64).
Safe to cache on a CDN.

- `GET /__nuxt_content/:collection/key?kid=<kid>`
Returns `{ kid, k }` where `k` is the short-lived base64-encoded AES key. The `kid` comes from the dump’s envelope and ensures the key matches the actual dump version, even if the SPA is stale.
Must be protected with **your authentication middleware**.
This endpoint is the only place the actual key is exposed.

### 2. Plaintext (no encryption)

- `GET /__nuxt_content/:collection/sql_dump.txt`
Returns the raw compressed SQL array (unsafe for private data).
Still available when `encryption.enabled = false`.

- `POST /__nuxt_content/:collection/query`
Runs an SQL query against the collection database.
Used internally by the client after the dump is hydrated.

## Offline access

When a dump has been decrypted once, the client can cache the derived key locally (keyed by `kid`). On subsequent loads, the cached key is tried first to allow reading content while offline. If it fails (e.g. after a redeploy with a new checksum), the client discards it and requests a fresh key.

## Enable encryption

```ts [nuxt.config.ts]
export default defineNuxtConfig({
content: {
encryption: {
enabled: true,
masterKey: process.env.NUXT_CONTENT_MASTER_KEY, // base64(32 bytes)
}
}
})
```

If `masterKey` isn't provided, Nuxt Content generates a random 32-byte key at build time and keeps it on the server.

Generate a master key:

```bash
openssl rand -base64 32
# or
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```

## Authentication middleware

You must protect the **key endpoint** and the `__nuxt_content` so only authenticated users receive decryption keys and content:

```
// server/middleware/content-auth.ts
import { defineEventHandler, createError, getRequestURL } from 'h3'

// --- Provided elsewhere. Do NOT implement here. ---
declare function getUser(event: any): any | null
declare function hasAccess(user: any, collection: string): boolean
declare function isAdmin(user: any): boolean
// --------------------------------------------------

// all collections prefixed course_ will be private each collection has a different key
const PRIVATE_COLLECTION_PREFIXES: string[] = ['course_']

function getCollectionFromPath(pathname: string): string | null {
const m = pathname.match(/\/__nuxt_content\/([^/]+)/)
return m ? m[1] : null
}

function isKeyEndpoint(pathname: string): boolean {
return /\/__nuxt_content\/[^/]+\/key\/?$/.test(pathname)
}

function isPrivateCollection(collection: string): boolean {
return PRIVATE_COLLECTION_PREFIXES.some(prefix =>
collection.startsWith(prefix)
)
}

export default defineEventHandler(async (event) => {
const url = getRequestURL(event).pathname

// Skip auth for prerender or build phases
if (
process.env.NODE_ENV === 'prerender' ||
process.env.npm_lifecycle_event === 'build'
) {
return
}

// Handle admin endpoints
if (url.includes('/api/admin')) {
const user = getUser(event)
if (!user || !isAdmin(user)) {
throw createError({
statusCode: 403,
statusMessage: 'Unauthorized',
})
}
return
}

// Only protect __nuxt_content routes
if (!url.includes('/__nuxt_content/')) return

const collection = getCollectionFromPath(url)
const forKey = isKeyEndpoint(url)

if (!collection) {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}

// Public collections (e.g. "blog") are always allowed
if (!isPrivateCollection(collection)) return

// Private collections: require user
const user = getUser(event)
if (!user) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
message: forKey
? 'Sign in to request a decryption key for this collection.'
: 'Sign in to access this collection.',
})
}

// Authorization check via provided helper
if (!hasAccess(user, collection)) {
throw createError({
statusCode: 403,
statusMessage: 'Forbidden',
message: forKey
? 'You do not have permission to obtain a key for this collection.'
: 'You do not have permission to access this collection.',
})
}

// If reached here: allowed
})
```

- The /__nuxt_content/:collection/key endpoint is invoked after this middleware.
- Because each collection (`course_*`) runs its own HKDF derivation, the API will hand out different decryption keys for different collections.
- A client with the course key cannot decrypt the premium dump, and vice versa β€” the separation is enforced cryptographically.

## Why encrypted dumps are secure

This design uses HKDF (HMAC-based Key Derivation Function) to ensure strong separation between collections:

- If you **don’t hand out a key** from `/__nuxt_content/:collection/key`, the client cannot decrypt that collection’s dump.
The encrypted file on the CDN is useless without the key.
- If you hand out a key for one collection (e.g. `posts`), the client can only decrypt that dump.
They cannot derive or guess the key for another collection (e.g. `docs`) because:
- The HKDF `info` parameter is different (`content:posts` vs `content:docs`).
- The server never shares the **master key**.
- Since the **kid** (which encodes the dump’s checksum) is included in the derivation, a new build with updated content produces a new key. Old keys won’t work with updated dumps.

## Summary

* Encrypted dumps are **safe static artifacts**.
* API endpoints provide either the encrypted blob or a short-lived key.
* Middleware is required to control who can fetch keys.
* Clients transparently decrypt and hydrate, preserving v3’s offline & fast querying benefits.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@
"unist-util-visit": "^5.0.0",
"ws": "^8.18.3",
"zod": "^3.25.75",
"zod-to-json-schema": "^3.24.6"
"zod-to-json-schema": "^3.24.6",
"uncrypto": "^0.1.3"
},
"peerDependencies": {
"@electric-sql/pglite": "*",
Expand Down
7 changes: 5 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 26 additions & 5 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { stat } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
import {
defineNuxtModule,
createResolver,
Expand Down Expand Up @@ -145,12 +146,22 @@ export default defineNuxtModule<ModuleOptions>({
// Prerender database.sql routes for each collection to fetch dump
nuxt.options.routeRules ||= {}

// @ts-expect-error - Prevent nuxtseo from indexing nuxt-content routes
// @see https://github.com/nuxt/content/pull/3299
nuxt.options.routeRules![`/__nuxt_content/**`] = { robots: false }
// Prevent nuxtseo from indexing nuxt-content routes
// @ts-expect-error - routeRules uses string index globs which Nuxt supports at runtime but TypeScript cannot type
nuxt.options.routeRules!['/__nuxt_content/**'] = { robots: false }

if (options.encryption?.enabled && !options.encryption.masterKey) {
options.encryption.masterKey = randomBytes(32).toString('base64')
}
const encryptionEnabled = !!(options.encryption?.enabled && options.encryption.masterKey)

manifest.collections.forEach((collection) => {
if (!collection.private) {
if (collection.private) return

if (encryptionEnabled) {
nuxt.options.routeRules![`/__nuxt_content/${collection.name}/sql_dump.enc`] = { prerender: true }
}
else {
nuxt.options.routeRules![`/__nuxt_content/${collection.name}/sql_dump.txt`] = { prerender: true }
}
})
Expand All @@ -166,13 +177,19 @@ export default defineNuxtModule<ModuleOptions>({
// Module Options
nuxt.options.runtimeConfig.public.content = {
wsUrl: '',
}
// Expose encryption status to client/runtime so fetchers know which endpoint to use.
encryptionEnabled,
} as never
nuxt.options.runtimeConfig.content = {
databaseVersion,
version,
database: options.database,
localDatabase: options._localDatabase!,
integrityCheck: true,
encryption: {
enabled: encryptionEnabled,
masterKey: encryptionEnabled ? options.encryption!.masterKey : undefined,
},
} as never

nuxt.hook('nitro:config', async (config) => {
Expand Down Expand Up @@ -237,6 +254,10 @@ export default defineNuxtModule<ModuleOptions>({
})

async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollection[], options: ModuleOptions) {
// unchanged … (keep your original implementation)
// NOTE: no changes needed below for the encryption feature
// (build-time dumps are already prepared; templates handle .sql vs .sql.enc)
// ----------------------------------------------------------------------------
const collectionDump: Record<string, string[]> = {}
const collectionChecksum: Record<string, string> = {}
const collectionChecksumStructure: Record<string, string> = {}
Expand Down
32 changes: 4 additions & 28 deletions src/presets/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,10 @@
import { addTemplate } from '@nuxt/kit'
import { join } from 'pathe'
import { logger } from '../utils/dev'
// src/presets/cloudflare.ts
import { definePreset } from '../utils/preset'
import { collectionDumpTemplate } from '../utils/templates'
import { applyContentDumpsPreset } from './shared-dumps'

export default definePreset({
name: 'cloudflare',
async setupNitro(nitroConfig, { manifest, resolver }) {
if (nitroConfig.runtimeConfig?.content?.database?.type === 'sqlite') {
logger.warn('Deploying to Cloudflare requires using D1 database, switching to D1 database with binding `DB`.')
nitroConfig.runtimeConfig!.content!.database = { type: 'd1', bindingName: 'DB' }
}

nitroConfig.publicAssets ||= []
nitroConfig.alias = nitroConfig.alias || {}
nitroConfig.handlers ||= []

// Add raw content dump
manifest.collections.map(async (collection) => {
if (!collection.private) {
addTemplate(collectionDumpTemplate(collection.name, manifest))
}
})

// Add raw content dump to public assets
nitroConfig.publicAssets.push({ dir: join(nitroConfig.buildDir!, 'content', 'raw'), maxAge: 60 })
nitroConfig.handlers.push({
route: '/__nuxt_content/:collection/sql_dump.txt',
handler: resolver.resolve('./runtime/presets/cloudflare/database-handler'),
})
async setupNitro(nitroConfig, ctx) {
applyContentDumpsPreset(nitroConfig, { ...ctx, platform: 'cloudflare' })
},

})
16 changes: 4 additions & 12 deletions src/presets/node.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import { addTemplate } from '@nuxt/kit'
import { fullDatabaseCompressedDumpTemplate } from '../utils/templates'
// src/presets/node.ts
import { definePreset } from '../utils/preset'
import { applyContentDumpsPreset } from './shared-dumps'

export default definePreset({
name: 'node',
setupNitro(nitroConfig, { manifest, resolver }) {
nitroConfig.publicAssets ||= []
nitroConfig.alias = nitroConfig.alias || {}
nitroConfig.handlers ||= []

nitroConfig.alias['#content/dump'] = addTemplate(fullDatabaseCompressedDumpTemplate(manifest)).dst
nitroConfig.handlers.push({
route: '/__nuxt_content/:collection/sql_dump.txt',
handler: resolver.resolve('./runtime/presets/node/database-handler'),
})
async setupNitro(nitroConfig, ctx) {
applyContentDumpsPreset(nitroConfig, { ...ctx, platform: 'node' })
},
})
Loading