Skip to content

feat(backups): admin backups — one-click download and scheduled archives to storage - #1890

Merged
ascorbic merged 5 commits into
emdash-cms:mainfrom
swissky:feat/admin-backups
Jul 11, 2026
Merged

feat(backups): admin backups — one-click download and scheduled archives to storage#1890
ascorbic merged 5 commits into
emdash-cms:mainfrom
swissky:feat/admin-backups

Conversation

@swissky

@swissky swissky commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds backups to the admin, closing the backup half of Discussion #142 (proposal with the full design rationale: #142 (comment)).

Settings → Backups (admin role) provides:

  • One-click download of a complete content backup: all content including drafts, scheduled, and trashed entries, collection/field definitions, taxonomies, menus, widgets, sections, SEO settings, revisions, media metadata, and site settings. Deliberately excluded: user accounts, sessions, credentials, API/OAuth tokens, and secrets (emdash:preview_secret, plugin config, passkey challenges) — the file is user-holdable, so nothing sensitive may leak into it.
  • Daily automatic backups to the site's own storage backend (R2/S3/local) under backups/, with configurable retention (1–30, oldest pruned first), a "Back up now" button, and a stored-archives list with per-archive download/delete. No Cloudflare API token needed — the app exports its own data through the existing Storage abstraction.
  • A pointer to D1 Time Travel for point-in-time restore, which Cloudflare users already have out of the box.

Implementation notes:

  • The export builds on the existing snapshot generator (api/handlers/snapshot.ts), extended with includeTrashed and a caller-supplied options-key allowlist. The snapshot route's behavior is unchanged (defaults preserved).
  • Scheduled runs piggyback on the existing maintenance tick (runScheduledTasks on Workers, the Node scheduler callback) — no new cron surface. A last-run gate (23h) makes the tick a cheap no-op the rest of the day; runs are non-fatal and never throw into the tick.
  • New admin-only backups:manage permission in rbac.ts; every route checks it.
  • Security hardening: archive filenames are validated against a strict pattern before touching a storage key (no traversal), carry an unguessable random suffix, and the public media file route now denies backups/ keys so archives in a shared bucket are unreachable without auth. Downloads are Content-Disposition: attachment, nosniff, private, no-store.
  • Restore is deliberately out of scope for this first cut (destructive; CLI-first design planned — see the Discussion). The docs page covers restore paths that exist today (Time Travel, wrangler d1 export/execute).
  • New docs page Guides → Backups covering what's in a backup, scheduled archives, Time Travel, and offsite dumps.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Fable 5

Screenshots / test output

Test Files  232 passed (232)        # unit
     Tests  3133 passed
Test Files  101 passed | 1 skipped  # integration
     Tests  1327 passed | 3 skipped

New tests: backup-handlers.test.ts (export fidelity incl. drafts/trash, secrets exclusion asserted on the raw payload, archive-name validation incl. traversal, retention pruning, scheduled-run gating) and backup-routes.test.ts (route registration, 401/403 on all six endpoints for anonymous/editor, media route denies backups/ without touching storage).

…ves to storage

Adds a Backups page under admin settings: download a complete content
backup (all content including drafts and trash, schema, taxonomies,
menus, widgets, media metadata, site settings — never users or secrets),
plus optional daily automatic backups written to the site's storage
bucket with configurable retention. Scheduled runs piggyback on the
existing maintenance tick. New admin-only backups:manage permission.
The public media route now denies keys under backups/.

Ref: Discussion emdash-cms#142
@changeset-bot

changeset-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2f9c8d7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,472 lines across 19 files. Large PRs are harder to review and more likely to be closed without review.
This PR spans 4 different areas (area/core, area/admin, area/docs, area/auth). Consider breaking it into smaller, focused PRs.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Jul 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1890

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1890

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1890

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1890

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1890

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1890

emdash

npm i https://pkg.pr.new/emdash@1890

create-emdash

npm i https://pkg.pr.new/create-emdash@1890

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1890

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1890

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1890

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1890

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1890

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1890

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1890

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1890

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1890

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1890

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1890

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1890

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1890

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1890

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1890

commit: 2f9c8d7

swissky added 2 commits July 9, 2026 11:38
…xposure caveat

LocalStorage.list matches directory + filename prefix rather than flat
keys, so include the emdash-backup- filename prefix in the list call.
Docs now warn that publicly exposed buckets serve archives by URL.

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a well-scoped, architecture-fitting backup feature. The implementation reuses the snapshot generator, adds a new admin-only backups:manage permission, protects archives from the public media route, validates storage keys before touching them, and wires scheduled runs into the existing maintenance tick. The tests cover export fidelity, secrets exclusion, archive-name validation, retention pruning, and route authorization. Overall approach is sound and the change is additive/minor as the changeset claims.

I didn't run tests/builds/lint, so I can't verify the reported green CI; I reviewed code and conventions statically. My concerns are about two AGENTS.md conventions in the new code:

  1. Admin UI color tokens: BackupSettings.tsx hand-rolls error/warning callouts with raw Tailwind colors and dark: prefixes (red-*, amber-*). AGENTS.md requires admin UI to use Kumo semantic tokens and explicitly forbids dark: prefixes; the project's own DialogError component already shows the right pattern (bg-kumo-danger/10, text-kumo-danger). These callouts should be switched to Kumo status tokens.

  2. Error-code centralization: The backup code and routes introduce ad-hoc error strings like BACKUP_SETTINGS_ERROR, BACKUP_CREATE_ERROR, BACKUP_LIST_ERROR, BACKUP_DELETE_ERROR, BACKUP_EXPORT_ERROR, and BACKUP_DOWNLOAD_ERROR instead of adding them to the central ErrorCode object in packages/core/src/api/errors.ts. The codebase's errors.ts explicitly says all handler/route error codes should be defined there; centralizing keeps mapErrorStatus and client-side error mapping consistent.

Both are fixable in small follow-up commits and don't change the public API shape.

<BackToSettingsLink />
<h1 className="text-2xl font-bold">{t`Backups`}</h1>
</div>
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This error banner uses raw Tailwind colors (border-red-200, bg-red-50, text-red-800) and dark: prefixes for theming. AGENTS.md requires admin UI to use Kumo semantic tokens and explicitly forbids dark: prefixes (Kumo tokens use light-dark() internally). The project already has the correct pattern in DialogError.tsx: bg-kumo-danger/10 and text-kumo-danger.

Suggested change
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200">
<div className="flex items-center gap-2 rounded-lg bg-kumo-danger/10 p-3 text-sm text-kumo-danger">

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cddba1d — the error banner now reuses the existing DialogError component instead of a hand-rolled callout.

Comment on lines +207 to +209
<div className="flex items-start gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
<WarningCircle className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This warning callout is hand-rolled with raw Tailwind amber/red colors and dark: variants. As above, AGENTS.md forbids raw Tailwind colors and dark: prefixes in admin components. Replace with Kumo status tokens (e.g. bg-kumo-warning/10, text-kumo-warning, border-kumo-warning/20) or use a Kumo-provided banner/callout component instead.

Suggested change
<div className="flex items-start gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
<WarningCircle className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
<div className="flex items-start gap-3 rounded-lg border border-kumo-warning/20 bg-kumo-warning/10 p-4">
<WarningCircle className="h-5 w-5 text-kumo-warning mt-0.5 flex-shrink-0" />
<p className="text-sm text-kumo-warning">

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cddba1d — switched to border-kumo-warning/50 bg-kumo-warning-tint text-kumo-warning (same tokens the WordPress-import warning callouts use), no raw Tailwind colors or dark: prefixes left.

enabled: input.enabled,
retention: clampRetention(input.retention),
};
const options = new OptionsRepository(db);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The backup code introduces ad-hoc error codes (BACKUP_SETTINGS_ERROR, BACKUP_LIST_ERROR, BACKUP_CREATE_ERROR, BACKUP_DELETE_ERROR) instead of adding them to the central ErrorCode enum in packages/core/src/api/errors.ts. errors.ts says "Routes and handlers should import error codes from this module instead of using ad-hoc strings." Adding them to ErrorCode keeps mapErrorStatus, logging, and client-side error mapping consistent.

Suggested change
const options = new OptionsRepository(db);
import { ErrorCode } from "./errors.js";
// ...
return {
success: false,
error: { code: ErrorCode.BACKUP_SETTINGS_ERROR, message: "Failed to update backup settings" },
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cddba1d — all backup codes (BACKUP_SETTINGS_READ_ERROR, BACKUP_SETTINGS_ERROR, BACKUP_EXPORT_ERROR, BACKUP_LIST_ERROR, BACKUP_CREATE_ERROR, BACKUP_DELETE_ERROR, BACKUP_DOWNLOAD_ERROR) are now registered in the central ErrorCode object, and the handler + all three route files reference ErrorCode.* instead of string literals.

<div className="min-w-0">
<div className="font-mono text-sm truncate">{archive.name}</div>
<div className="text-sm text-kumo-subtle">
{new Date(archive.lastModified).toLocaleString()} · {formatBytes(archive.size)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This archive timestamp is formatted with the browser-default toLocaleString(). Since the admin locale is bound through Lingui, use the active Lingui locale (or a date formatter from @lingui/core) so the displayed date/time matches the user's chosen locale.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cddba1d — timestamps now go through i18n.date(…, { dateStyle: "medium", timeStyle: "short" }) from the active Lingui context.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 9, 2026
…ocalized dates

- Error/warning callouts use Kumo semantic tokens (DialogError,
  kumo-warning) instead of raw Tailwind colors and dark: prefixes
- Backup error codes registered in the central ErrorCode object and
  referenced from handler and routes
- Archive timestamps formatted through the active Lingui locale
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review overlap and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 9, 2026
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 10, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Good addition. Just one note about the docs

Comment on lines +53 to +65
<Aside type="caution">
Archives share the bucket with your media files. EmDash's media route refuses to serve anything
under `backups/`, and archive names carry a random suffix — but if you expose the bucket itself
through a public R2 domain or CDN, anything in it is reachable by URL. Prefer serving media
through EmDash (or scope your public domain to the media prefix).
</Aside>

<Aside type="note">
Automatic backups piggyback on the scheduled maintenance tick (the same mechanism that powers
scheduled publishing). On Cloudflare this is the Worker's cron trigger; on Node it's the built-in
scheduler. If your deployment has no cron trigger configured, use **Back up now** or the download
button instead.
</Aside>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having multiple Asides next to each other looks messy and reduces the visiblility of things that actually need highlighting. I'd drop the note one, and just leave the caution

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me. Thank you

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2f9c8d7 — the note aside is gone; only the caution remains boxed. The cron detail (scheduled backups ride the maintenance tick, use Back up now without a cron trigger) is kept as plain body text below it.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 10, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 11, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@ascorbic
ascorbic merged commit 82827d3 into emdash-cms:main Jul 11, 2026
49 checks passed
@emdashbot emdashbot Bot mentioned this pull request Jul 11, 2026
marcusbellamyshaw-cell pushed a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 22, 2026
…ves to storage (emdash-cms#1890)

* feat(backups): admin backups — one-click download and scheduled archives to storage

Adds a Backups page under admin settings: download a complete content
backup (all content including drafts and trash, schema, taxonomies,
menus, widgets, media metadata, site settings — never users or secrets),
plus optional daily automatic backups written to the site's storage
bucket with configurable retention. Scheduled runs piggyback on the
existing maintenance tick. New admin-only backups:manage permission.
The public media route now denies keys under backups/.

Ref: Discussion emdash-cms#142

* fix(backups): make archive listing work on LocalStorage, doc bucket-exposure caveat

LocalStorage.list matches directory + filename prefix rather than flat
keys, so include the emdash-backup- filename prefix in the list call.
Docs now warn that publicly exposed buckets serve archives by URL.

* chore: revert local typegen churn in demo

* refactor(backups): address review — Kumo tokens, central ErrorCode, localized dates

- Error/warning callouts use Kumo semantic tokens (DialogError,
  kumo-warning) instead of raw Tailwind colors and dark: prefixes
- Backup error codes registered in the central ErrorCode object and
  referenced from handler and routes
- Archive timestamps formatted through the active Lingui locale

* docs: drop the stacked note aside, keep the cron detail as body text
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin area/auth area/core area/docs bot:review Trigger an emdashbot code review on this PR cla: signed overlap review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants