Skip to content

Commit f35c7d4

Browse files
authored
docs: correct unsafe deployment and recovery guidance (emdash-cms#3029)
* docs: correct unsafe deployment and recovery guidance * docs: clarify safety guidance * docs: trim repeated safety guidance
1 parent 4f3d6ad commit f35c7d4

6 files changed

Lines changed: 115 additions & 84 deletions

File tree

docs/src/content/docs/deployment/cloudflare.mdx

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -454,29 +454,22 @@ the provider under **Settings → Email**.
454454

455455
## Environment Variables
456456

457-
### Recommended: encryption key
457+
### Encryption key validation
458458

459-
`EMDASH_ENCRYPTION_KEY` is the key for encrypting plugin secrets at
460-
rest (webhook tokens, Turnstile keys, etc.). The key is validated on
461-
startup; plugin secret encryption uses it once enabled. Set it on
462-
every deployment so secrets are protected without a later config
463-
change.
459+
`EMDASH_ENCRYPTION_KEY` does not currently encrypt plugin secrets. If the variable is set, EmDash
460+
checks its format during startup, but plugin secret values remain unencrypted in the database.
464461

465-
The key is provided by you and never stored in the database; only
466-
encrypted ciphertext is. Losing it means losing every secret encrypted
467-
with it.
468-
469-
Generate a key and store it as a Worker secret with the following commands:
462+
If you set the variable, generate a valid value and store it as a Worker secret with the following
463+
commands:
470464

471465
```bash
472466
npx emdash secrets generate
473467
wrangler secret put EMDASH_ENCRYPTION_KEY
474468
```
475469

476470
<Aside type="caution">
477-
Never commit the encryption key to your repository, and back it up
478-
somewhere durable (a password manager, KMS, or your team's secret
479-
store). The plain text value lives only in your environment.
471+
Treat the database and its backups as sensitive because they contain plaintext plugin secrets.
472+
Never commit secret values to your repository.
480473
</Aside>
481474

482475
### Optional: stable-value overrides
@@ -493,7 +486,9 @@ needs to share the secret with your main site.
493486
| `EMDASH_IP_SALT` | Override for the auto-generated commenter-IP hash salt. |
494487
| `EMDASH_AUTH_SECRET` | Optional. If set, it is used as the IP-salt source (unless `EMDASH_IP_SALT` is also set, which takes precedence), keeping commenter-IP hashes stable for installs that already rely on it. Leave it unset for a new deployment. |
495488

496-
Access environment variables in your configuration using `import.meta.env` or the Cloudflare `env` binding.
489+
Read EmDash secrets from `process.env` at runtime. Read Worker bindings from `env`, imported from
490+
`cloudflare:workers`. Never read secrets through `import.meta.env`: Vite replaces those values at
491+
build time and can write them into the server bundle.
497492

498493
For the complete inventory of every secret EmDash uses — including storage locations, rotation steps, and what breaks when a key is lost — see [Secrets & Key Management](/deployment/secrets/).
499494

docs/src/content/docs/deployment/nodejs.mdx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -170,23 +170,20 @@ docker compose up -d
170170

171171
## Environment Variables
172172

173-
### Recommended: encryption key
173+
### Encryption key validation
174174

175-
`EMDASH_ENCRYPTION_KEY` is the key for encrypting plugin secrets at
176-
rest. The key is validated on startup; plugin secret encryption uses
177-
it once enabled. Set it on every deployment so secrets are protected
178-
without a later config change.
175+
`EMDASH_ENCRYPTION_KEY` does not currently encrypt plugin secrets. If the variable is set, EmDash
176+
checks its format during startup, but plugin secret values remain unencrypted in the database.
179177

180-
Generate a key and add the result to your environment:
178+
If you set the variable, generate a valid value and add the result to your environment:
181179

182180
```bash
183181
npx emdash secrets generate # add the result to your environment
184182
```
185183

186-
The key is provided by you and never stored in the database; only
187-
encrypted ciphertext is. Back it up somewhere durable (a password
188-
manager, KMS, or your team's secret store) — losing it means losing
189-
every secret encrypted with it.
184+
The value is operator-provided and is not stored in the database. Losing it has no current
185+
data-recovery impact because no stored data depends on it. Treat the database and its backups as
186+
sensitive because they contain plaintext plugin secrets.
190187

191188
### Optional: stable-value overrides
192189

docs/src/content/docs/deployment/secrets.mdx

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ EmDash uses a small set of secrets across previews, comments, authentication, st
1111

1212
| Secret | Source | Stored in | Lost key impact |
1313
| -------------------------------------------------- | --------------------------------- | ---------------------------------------- | ----------------------------------------------- |
14-
| [`EMDASH_ENCRYPTION_KEY`](#the-encryption-key) | Operator (`emdash secrets generate`) | Environment / Worker secret only | Encrypted plugin secrets become unrecoverable (once encryption at rest ships) |
14+
| [`EMDASH_ENCRYPTION_KEY`](#the-encryption-key) | Operator (`emdash secrets generate`) | Environment / Worker secret only | No current data impact; EmDash only checks its format |
1515
| [Preview secret](#preview-secret) | Auto-generated (env override) | `options` table (`emdash:preview_secret`) | Outstanding preview links stop working; new ones are fine |
1616
| [IP salt](#ip-salt) | Auto-generated (env override) | `options` table (`emdash:ip_salt`) | Past comment rate-limit continuity resets |
1717
| [Session & API tokens](#session-and-api-tokens) | Generated per session/token | Session store / database (hashes only) | Nothing — plaintext is never stored |
@@ -24,9 +24,12 @@ EmDash uses a small set of secrets across previews, comments, authentication, st
2424

2525
## The encryption key
2626

27-
`EMDASH_ENCRYPTION_KEY` is the site's key for encrypting plugin secrets at rest. It is **operator-provided and never stored in the database** — the database only ever holds ciphertext, so a leaked database backup does not expose the key.
27+
`EMDASH_ENCRYPTION_KEY` does not currently encrypt plugin secrets or any other stored data. If the
28+
variable is set, EmDash checks its format during startup. A malformed value produces an
29+
operator-facing log message, but the site continues to handle requests.
2830

29-
Generate one and set it as an environment variable (or Worker secret):
31+
The following command generates a correctly formatted value. Store it in the runtime environment
32+
or as a Worker secret if your deployment uses this variable.
3033

3134
```bash
3235
npx emdash secrets generate
@@ -36,23 +39,11 @@ npx emdash secrets generate
3639
wrangler secret put EMDASH_ENCRYPTION_KEY
3740
```
3841

39-
The format is `emdash_enc_v1_` followed by 32 random bytes as unpadded base64url. The key is validated at runtime startup; a malformed value logs an operator-facing error without taking down request paths.
42+
The format is `emdash_enc_v1_` followed by 32 random bytes as unpadded base64url. The value is operator-provided and is not stored in the database. Losing it has no current data-recovery impact because no stored data depends on it.
4043

4144
<Aside type="caution">
42-
Back the key up somewhere durable (password manager, KMS, your team's secret store). Losing it
43-
means losing every secret encrypted with it — the ciphertext in the database cannot be recovered.
44-
</Aside>
45-
46-
### Rotation
47-
48-
The variable accepts a comma-separated list of keys. The **first** entry is the primary and is used for new writes; all entries are tried for decryption. Every encrypted value is tagged with an 8-character key fingerprint (the _kid_, printable via `emdash secrets fingerprint <key>`), so the runtime picks the right key automatically.
49-
50-
To rotate: generate a new key, prepend it to the list (`EMDASH_ENCRYPTION_KEY="new,old"`), redeploy, and drop the old key once existing values have been re-encrypted.
51-
52-
<Aside type="note">
53-
The encryption layer that consumes this key is not active yet — today the key is validated but
54-
inert, and [plugin secrets](#plugin-secrets) are stored unencrypted. Set the key now so your
55-
deployment is ready the moment plugin-secret encryption ships, without a config scramble.
45+
Plugin secret values are stored as plaintext in the database. Setting
46+
`EMDASH_ENCRYPTION_KEY` does not protect them. Restrict access to the database and its backups.
5647
</Aside>
5748

5849
## Generated site secrets
@@ -106,8 +97,7 @@ Settings a plugin declares with `type: "secret"` (API keys for email providers,
10697
<Aside type="caution">
10798
Plugin secrets are currently stored **unencrypted** in the database. Anyone with database access
10899
(including backups) can read them. Prefer scoped, revocable API keys, and treat database backups
109-
as sensitive. Encryption at rest using [`EMDASH_ENCRYPTION_KEY`](#the-encryption-key) is planned;
110-
setting the key today requires no migration later.
100+
as sensitive.
111101
</Aside>
112102

113103
- **Rotation:** rotate the key at the provider and paste the new value into the plugin's settings page. Takes effect immediately.
@@ -134,7 +124,6 @@ The separate `emdash-plugin` CLI (package `@emdash-cms/plugin-cli`) targets the
134124

135125
| I want to… | Do this |
136126
| --------------------------------------- | ------------------------------------------------------------------------------ |
137-
| Rotate the encryption key | Prepend a new key: `EMDASH_ENCRYPTION_KEY="new,old"`, redeploy, drop old later |
138127
| Invalidate all preview links | Delete the `emdash:preview_secret` option row (or change the env override) |
139128
| Reset comment rate-limit hashing | Change `EMDASH_IP_SALT` (or delete the `emdash:ip_salt` option row) |
140129
| Revoke a leaked API token | Admin → Users → API tokens → revoke, then create a replacement |

docs/src/content/docs/guides/backups.mdx

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,43 @@ description: Download site backups, schedule automatic backups to storage, and r
55

66
import { Aside, Steps } from "@astrojs/starlight/components";
77

8-
EmDash gives you three layers of protection for your content, from zero-config point-in-time recovery on Cloudflare to downloadable archives you keep yourself.
8+
Use a JSON backup when you need an offline copy of selected content data. Use a raw database backup
9+
or point-in-time recovery when you need to restore the site.
910

1011
## What's in a backup
1112

12-
A backup contains everything needed to reconstruct your site's content:
13+
A JSON backup includes:
1314

1415
- All content entries, including drafts, scheduled posts, and trashed items
15-
- Collection and field definitions (your content model)
16-
- Taxonomies and term assignments
17-
- Menus, widgets, sections, and SEO settings
18-
- Revisions and media metadata
19-
- Site settings (title, tagline, display preferences)
20-
21-
Backups deliberately **exclude**:
22-
23-
- User accounts, sessions, passkeys, and API tokens — auth data is neither portable nor safe in a downloadable file
24-
- Secrets (preview signing secret, plugin configuration)
25-
- Media binaries — the actual files live in your storage bucket (R2, S3, or local); a backup carries their metadata so references stay intact
16+
- Collection and field definitions that make up the content model
17+
- Taxonomy definitions, terms, and the terms assigned to each entry
18+
- Menus and menu items, sections, widget areas and widgets, SEO records, revision history, media
19+
metadata, and database migration history
20+
- Site settings such as the title, tagline, URL, locale, logo, display preferences, social profiles,
21+
and SEO defaults. These come from the `site:`, `emdash:site_`, and `emdash:locale` setting groups.
22+
23+
It omits all other database tables, including:
24+
25+
- User accounts, sessions, passkeys, OAuth data, API tokens, and other authentication data
26+
- Plugin storage and plugin settings, including plugin secrets
27+
- Comments and reactions, redirects and 404 logs, bylines, content relations and references, audit logs, rate limits, and scheduled-task state
28+
- Media folders, records of where media is used, incomplete or in-progress uploads, and the media
29+
files themselves
30+
- Other site options, including the preview signing secret and backup schedule
2631

2732
Backups are JSON files in the same snapshot format used by EmDash's preview system, versioned with the EmDash release that created them.
2833

34+
<Aside type="caution">
35+
EmDash does not implement restoring this JSON format. Do not rely on a JSON export as a recovery
36+
point before a destructive operation. Use a raw database backup or point-in-time recovery instead.
37+
</Aside>
38+
2939
## One-click download
3040

3141
Under **Settings → Backups** in the admin, the **Download backup** button generates a fresh backup and downloads it as a JSON file. Requires the admin role.
3242

33-
This is the right tool before risky operations: bulk imports, schema changes, or major upgrades.
43+
The download is for inspection or custom migration tooling. Before bulk imports, schema changes, or
44+
major upgrades, create a restorable database backup using one of the options below.
3445

3546
## Automatic backups to storage
3647

@@ -78,12 +89,6 @@ Time Travel keeps 30 days of history on the paid plan (7 days on free) with minu
7889

7990
See the [D1 Time Travel documentation](https://developers.cloudflare.com/d1/reference/time-travel/) for details.
8091

81-
<Aside type="tip">
82-
Time Travel and EmDash backups complement each other: Time Travel is your disaster-recovery net
83-
for the last 30 days; downloaded backups are user-holdable archives with no expiry that survive
84-
account or database deletion.
85-
</Aside>
86-
8792
## Offsite database dumps
8893

8994
For a complete SQL dump of the raw database (including users and auth tables), use Wrangler:
@@ -92,12 +97,15 @@ For a complete SQL dump of the raw database (including users and auth tables), u
9297
npx wrangler d1 export my-database --remote --output=backup.sql
9398
```
9499

95-
On Node deployments, the database is a single SQLite file — copy it while the server is stopped, or use `sqlite3 emdash.db ".backup backup.db"` for a consistent online copy.
100+
Restore that raw SQL dump with the following command:
101+
102+
```bash
103+
npx wrangler d1 execute my-database --remote --file=backup.sql
104+
```
96105

97-
## Restoring a backup
106+
On Node deployments, the database is a single SQLite file — copy it while the server is stopped, or use `sqlite3 emdash.db ".backup backup.db"` for a consistent online copy.
98107

99-
Restoring from a backup JSON is intentionally not exposed as a one-click admin action yet — overwriting a live database deserves more friction than a button. Current options:
108+
## JSON restore
100109

101-
- **Cloudflare:** use D1 Time Travel (above) for point-in-time restore.
102-
- **Full dumps:** import a `wrangler d1 export` dump with `npx wrangler d1 execute my-database --remote --file=backup.sql`.
103-
- **Backup JSON:** the format matches EmDash's snapshot format; a guided CLI restore is planned. Track [Discussion #142](https://github.com/emdash-cms/emdash/discussions/142).
110+
EmDash has no admin action, API endpoint, or CLI command for JSON restore. Use D1 Time Travel, a raw
111+
D1 SQL dump, or a copy of the SQLite database as described above.

docs/src/content/docs/guides/working-with-content.mdx

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ Perform actions on multiple entries at once:
211211
3. Select an action:
212212
- **Publish** - Set all selected to published
213213
- **Archive** - Set all selected to archived
214-
- **Delete** - Permanently remove selected
214+
- **Delete** - Move selected entries to Trash
215215

216216
4. Confirm the action
217217

@@ -254,7 +254,7 @@ When the publication date arrives, the content automatically becomes published.
254254

255255
## Deleting Content
256256

257-
Delete content from the edit screen or content list:
257+
Delete content from the edit screen or content list to move it to Trash:
258258

259259
### From the Editor
260260

@@ -272,9 +272,19 @@ Delete content from the edit screen or content list:
272272

273273
3. Confirm the deletion
274274

275+
### Restore from Trash
276+
277+
1. Open the collection that contained the entry.
278+
279+
2. Select the **Trash** tab above the content list.
280+
281+
3. Find the entry and select its **Restore** action.
282+
283+
The entry returns to the collection with its previous content and status.
284+
275285
<Aside type="caution">
276-
Deleted content is permanently removed and cannot be recovered. Consider archiving instead if you
277-
might need the content later.
286+
Administrators also see a separate **Permanently delete** action in Trash. Permanent deletion
287+
cannot be undone.
278288
</Aside>
279289

280290
## Content API
@@ -291,37 +301,65 @@ Content-Type: application/json
291301
Authorization: Bearer YOUR_API_TOKEN
292302

293303
{
294-
"title": "My New Post",
304+
"data": {
305+
"title": "My New Post",
306+
"content": [
307+
{
308+
"_type": "block",
309+
"style": "normal",
310+
"children": [{ "_type": "span", "text": "Post content goes here." }]
311+
}
312+
]
313+
},
295314
"slug": "my-new-post",
296-
"content": "<p>Post content here</p>",
297315
"status": "draft"
298316
}
299317
```
300318

301319
### Update Content
302320

303-
The following request updates an existing post and publishes it:
321+
The following request updates an existing post. Put collection fields inside `data`. Pass the
322+
opaque `_rev` from the latest GET response to reject the write if the entry changed in the
323+
meantime:
304324

305325
```bash
306326
PUT /_emdash/api/content/posts/my-new-post
307327
Content-Type: application/json
308328
Authorization: Bearer YOUR_API_TOKEN
309329

310330
{
311-
"title": "Updated Title",
312-
"status": "published"
331+
"data": {
332+
"title": "Updated Title"
333+
},
334+
"_rev": "REVISION_TOKEN"
335+
}
336+
```
337+
338+
Updating content saves a draft revision. Publish that revision with the publish endpoint, using the
339+
new `_rev` returned by the update response:
340+
341+
```bash
342+
POST /_emdash/api/content/posts/my-new-post/publish
343+
Content-Type: application/json
344+
Authorization: Bearer YOUR_API_TOKEN
345+
346+
{
347+
"_rev": "UPDATED_REVISION_TOKEN"
313348
}
314349
```
315350

316351
### Delete Content
317352

318-
The following request permanently deletes a post:
353+
The following request moves a post to Trash:
319354

320355
```bash
321356
DELETE /_emdash/api/content/posts/my-new-post
322357
Authorization: Bearer YOUR_API_TOKEN
323358
```
324359

360+
Restore it with `POST /_emdash/api/content/posts/my-new-post/restore`. Administrators can
361+
permanently delete it with `DELETE /_emdash/api/content/posts/my-new-post/permanent`.
362+
325363
## Translating Content
326364

327365
When [i18n is enabled](/guides/internationalization/), you can create translations of any content entry.

docs/src/content/docs/plugins/creating-plugins/api-routes.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ routes: {
114114

115115
Private routes require the `X-EmDash-Request: 1` CSRF header for cookie-authenticated requests. The admin UI sends it automatically; token-authenticated requests are exempt.
116116

117-
To opt a route out of auth and CSRF, mark it `public: true`:
117+
To opt a route out of authentication, mark it `public: true`:
118118

119119
```typescript
120120
routes: {
@@ -130,7 +130,11 @@ routes: {
130130
```
131131

132132
<Aside type="caution">
133-
Public routes skip authentication, scope, and CSRF entirely. Anyone on the internet can call them. Use `public: true` only for endpoints that need to accept external traffic — webhooks, public-facing search endpoints — and validate the input carefully.
133+
A public route skips authentication and token scope checks, but not protection against cross-origin
134+
browser requests. For requests that can change data, EmDash accepts a matching site `Origin` or the
135+
`X-EmDash-Request: 1` header; a different or malformed `Origin` returns 403. Webhooks and other
136+
non-browser clients usually omit `Origin` and remain allowed, so anyone can call a public route.
137+
Validate all input and verify the service's signature or token when available.
134138
</Aside>
135139

136140
### The authenticated caller

0 commit comments

Comments
 (0)