Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4ccffc7
feat: sql dump encryption
oripka Sep 21, 2025
148101d
feat: prepare to publish as @ripka/content
oripka Sep 24, 2025
b66e908
Revert "feat: prepare to publish as @ripka/content"
oripka Sep 24, 2025
ae51da3
fix
oripka Sep 24, 2025
5cff72e
feat: add clearContentClientStorage as helper to clear clientStorage.…
oripka Oct 14, 2025
c319f53
feat: garbage collection for local storage and handle quota exceed
oripka Nov 18, 2025
b73e05e
nump: version
oripka Nov 18, 2025
e952183
feat: cleanup old keys once a new version is successfully loaded
oripka Nov 18, 2025
a6c0e0f
fix: Fix key endpoint to ignore kid-supplied collection
oripka Nov 18, 2025
e3fc43f
bump version
oripka Nov 18, 2025
b991d68
fix: now imports subtle from uncrypto and uses it for AES-GCM import/…
oripka Nov 18, 2025
2d9b243
fix: A corrupt or outdated dump means we need a hard refresh to pick …
oripka Nov 19, 2025
7335ce6
Merge remote-tracking branch 'origin/main' into feat-encrypted-sql-du…
oripka Nov 19, 2025
b9cb1ba
fix: avoid double preset setup and preserve encryption config
oripka Nov 19, 2025
a5f07a4
fix
oripka Nov 19, 2025
75a685c
fix
oripka Nov 19, 2025
27d0b9a
fix
oripka Nov 19, 2025
f168ee2
feat: add useContentUpdates composable
oripka Nov 19, 2025
322ebf6
bump: version
oripka Nov 19, 2025
1393332
Merge remote-tracking branch 'origin/main' into feat-encrypted-sql-du…
oripka Dec 11, 2025
09b8c8e
fix: db
oripka Dec 11, 2025
7549d72
fix: Prevent infinite client-side reloads using a session-based count…
oripka Dec 11, 2025
7af22a8
refactor: standardize `Number.parseInt` usage and clarify OAuth provi…
oripka Dec 11, 2025
0e20684
fix: version
oripka Dec 11, 2025
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
19 changes: 19 additions & 0 deletions docs/content/docs/1.getting-started/3.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,25 @@ Controls whether content hot reloading is enabled during development.
The content watcher only runs in development and leverages the Vite dev server to detect content updates and send events to your application for live updates.
::

## `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
102 changes: 102 additions & 0 deletions docs/content/docs/4.utils/5.use-content-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
---
title: useContentUpdates
description: The useContentUpdates composable detects new Nuxt Content bundles and lets you trigger a controlled refresh in your app.
---

## Usage

`useContentUpdates` is auto-imported like the other Nuxt Content utilities. Call it inside your components to get a reactive flag that indicates whether fresh content is available, plus helpers to check the current status or force a reload when you decide to surface a button.

```vue [components/ContentUpdateBanner.vue]
<script setup lang="ts">
const {
hasUpdate,
isChecking,
checkForContentUpdate,
refreshContent,
} = useContentUpdates()

const onCheck = async () => {
await checkForContentUpdate()
}

const onRefresh = () => {
refreshContent('user-refresh')
}
</script>

<template>
<div>
<button :disabled="isChecking" @click="onCheck">
{{ isChecking ? 'Checking…' : 'Check for content updates' }}
</button>
<button v-if="hasUpdate" @click="onRefresh">
New content available — reload
</button>
</div>
</template>
```

::tip
`checkForContentUpdate()` performs a lightweight fetch to `/__nuxt_content/manifest.json` and compares versions with the bundle the user is currently running. Nothing is reloaded automatically—you are in control of when to call `refreshContent()`.
::

## Type

```ts
type ContentUpdatePayload = {
currentVersion: Ref<string>
latestVersion: Ref<string | null>
hasUpdate: Ref<boolean>
isChecking: Ref<boolean>
lastChecked: Ref<number | null>
error: Ref<string | null>
checkForContentUpdate(): Promise<boolean>
refreshContent(reason?: string): Promise<void>
}

function useContentUpdates(): ContentUpdatePayload
```

## API

### `checkForContentUpdate()`

Checks whether a newer manifest version exists.

- Returns: `Promise<boolean>` — `true` when a newer version was detected.
- Side effects: updates the reactive state (`latestVersion`, `hasUpdate`, `lastChecked`, `error`).

### `refreshContent(reason?: string)`

Calls the built-in `forceClientRefresh()` helper to clear cached dumps and reload the page.

- `reason`: Optional string for diagnostics (appears in reload telemetry).
- Returns: `Promise<void>`

## Example

Provide a global snackbar/banner when a newer bundle is published:

```vue [app.vue]
<script setup lang="ts">
const { hasUpdate, checkForContentUpdate, refreshContent } = useContentUpdates()

onMounted(() => {
const interval = window.setInterval(() => {
checkForContentUpdate()
}, 60_000)
onBeforeUnmount(() => window.clearInterval(interval))
})
</script>

<template>
<NuxtPage />
<div v-if="hasUpdate" class="update-banner">
<span>Fresh docs are ready.</span>
<button @click="refreshContent()">Reload</button>
</div>
</template>
```

Because Nuxt Content does not enforce auto-refreshes, this pattern lets you keep control over the UX (show only when it makes sense, batch by route, etc.).
226 changes: 226 additions & 0 deletions docs/content/docs/8.advanced/9.private.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
---
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.

## Clearing cached dumps on logout

When a user signs out you should clear any cached dumps and derived encryption keys stored in the browser. Nuxt Content exposes a helper that wipes the in-memory SQLite database and any related `localStorage` entries:

```ts [logout.ts]
import { clearContentClientStorage } from '#imports'

export async function logout() {
// ...perform your logout logic

await clearContentClientStorage()
}
```

Pass an explicit list of collections if you only need to invalidate a subset:

```ts [logout.ts]
await clearContentClientStorage({
collections: ['course_private']
})
```

## 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:

```ts
// 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.
Loading
Loading