Skip to content

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

Open
swissky wants to merge 4 commits into
emdash-cms:mainfrom
swissky:feat/admin-backups
Open

feat(backups): admin backups — one-click download and scheduled archives to storage#1890
swissky wants to merge 4 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: cddba1d

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant