feat(backups): admin backups — one-click download and scheduled archives to storage#1890
feat(backups): admin backups — one-click download and scheduled archives to storage#1890swissky wants to merge 4 commits into
Conversation
…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 detectedLatest commit: cddba1d The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
Scope checkThis PR changes 1,472 lines across 19 files. Large PRs are harder to review and more likely to be closed without review. 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. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
…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.
There was a problem hiding this comment.
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:
-
Admin UI color tokens:
BackupSettings.tsxhand-rolls error/warning callouts with raw Tailwind colors anddark:prefixes (red-*,amber-*). AGENTS.md requires admin UI to use Kumo semantic tokens and explicitly forbidsdark:prefixes; the project's ownDialogErrorcomponent already shows the right pattern (bg-kumo-danger/10,text-kumo-danger). These callouts should be switched to Kumo status tokens. -
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, andBACKUP_DOWNLOAD_ERRORinstead of adding them to the centralErrorCodeobject inpackages/core/src/api/errors.ts. The codebase'serrors.tsexplicitly says all handler/route error codes should be defined there; centralizing keepsmapErrorStatusand 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"> |
There was a problem hiding this comment.
[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.
| <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"> |
There was a problem hiding this comment.
Fixed in cddba1d — the error banner now reuses the existing DialogError component instead of a hand-rolled callout.
| <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"> |
There was a problem hiding this comment.
[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.
| <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"> |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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.
| 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" }, | |
| }; |
There was a problem hiding this comment.
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)} |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
Fixed in cddba1d — timestamps now go through i18n.date(…, { dateStyle: "medium", timeStyle: "short" }) from the active Lingui context.
…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
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
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:
emdash:preview_secret, plugin config, passkey challenges) — the file is user-holdable, so nothing sensitive may leak into it.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 existingStorageabstraction.Implementation notes:
api/handlers/snapshot.ts), extended withincludeTrashedand a caller-supplied options-key allowlist. The snapshot route's behavior is unchanged (defaults preserved).runScheduledTaskson 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.backups:managepermission inrbac.ts; every route checks it.backups/keys so archives in a shared bucket are unreachable without auth. Downloads areContent-Disposition: attachment,nosniff,private, no-store.wrangler d1 export/execute).Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
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) andbackup-routes.test.ts(route registration, 401/403 on all six endpoints for anonymous/editor, media route deniesbackups/without touching storage).