* 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 #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
* feat: accept invites via OAuth (Google/GitHub), not just passkeys
An invited user can complete their invite by signing in with a configured
OAuth provider. The invite token is carried through the OAuth state, and the
callback completes the invite (creating the user with the invited role and
linking the account) only when the provider-verified email matches the invited
address. The invite-accept page renders the configured providers under an
'Or continue with' divider.
* fix: consume invite token when linking existing account; distinct unverified-email error
Addresses review on #1868:
- acceptInviteViaOAuth now deletes the invite token in the existing-user and
existing-account branches, preserving the single-use guarantee.
- Unverified provider email throws a new invite_email_unverified code with its
own callback message, instead of the misleading email-mismatch message.
* fix: address review — linked-user email guard, invite-token validation, changeset
- acceptInviteViaOAuth: in the already-linked branch, require the linked user's
email to match the invite before consuming, so an identity whose email changed
after linking can't consume someone else's invite.
- oauth/[provider]: validate the ?invite= token shape/length before persisting
to the (unauthenticated) OAuth state store.
- changeset: rewrite as a single user-facing sentence.
* ci: update query-count snapshots
---------
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
* ci: release
* format
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <m@mk.gg>
* ci: release
* format
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <m@mk.gg>
* fix(workerd): clear all lint warnings and tsgo errors
- Replace untyped `as T` casts in bridge dispatch with predicate-backed `require*`/`optional*` helpers
- Introduce `asContentDb()` for dynamic ec_* tables (single justified narrowing)
- Drop unnecessary `as keyof Database` casts for tables already in the static schema
- Validate marshaled RequestInit at the http/fetch boundary
- Typed HttpError class in backing-service for status-bearing errors
- getPluginStorageConfig now returns the real PluginStorageConfig shape
- WorkerdSandboxedPlugin implements SandboxedPluginInstance (the previous SandboxedPlugin symbol did not exist)
- Add typecheck script so the package participates in pnpm typecheck
No runtime behaviour changes.
* chore: bump oxlint and update disable-comment format
Upgrade oxlint (1.49 -> 1.66) and oxlint-tsgolint (0.15 -> 0.23). The
newer oxlint renamed the unused unicorn/prevent-abbreviations rule and
switched the canonical typescript-eslint plugin name to typescript,
which changes how inline disable comments are written.
Mechanical rename: `typescript-eslint(rule-name)` -> `typescript/rule-name`
in all eslint-disable comments. Without this, ~120 disable comments
stopped suppressing the rules they were meant to.
Also drops unicorn/prevent-abbreviations from .oxlintrc.json (no longer
a valid rule).
* ci: gate lint warnings so they cannot regress
`pnpm lint` now passes `--deny-warnings` to oxlint, so any warning is
a non-zero exit. CI inherits this through the existing lint job.
The blocker was the existing pile of warnings, so this commit also
clears them:
- `packages/core/src/astro/middleware.ts`: collapse duplicated
`virtualSandboxRunnerModule as Record<...>` casts behind one local
binding inside a block-form disable, and convert the remaining
parenthesis-form disables that newer oxlint stopped recognising.
- `packages/core/src/astro/middleware/auth.ts` and 14 API route files:
drop unnecessary `emdash!` non-null assertions; the preceding
`requireDb(emdash?.db)` guard already narrows.
- `packages/core/src/emdash-runtime.ts`: drop unnecessary
`as ResolvedPlugin[]` and `emdash!` casts; annotate the two
remaining trusted dynamic-import sites with a single disable line.
- `packages/plugin-cli/src/build/pipeline.ts`: replace the chain of
`as Record<string, unknown>` casts with `isRecord` /
`isStringArray` predicate narrowing.
- `packages/cloudflare/src/sandbox/bridge.ts`: drop the
`this.env.DB as D1Database` cast (already that type).
- `packages/core/src/client/index.ts`,
`packages/core/src/astro/integration/index.ts`,
`packages/registry-client/src/credentials/index.ts`: remove three
unused imports.
Newer oxlint flagged config-level issues too:
- `packages/workerd/tsconfig.json`: add explicit `rootDir`.
- `packages/contentful-to-portable-text/tsconfig.json`: drop the
`rootDir` that excluded `test/**/*` from `include`.
- `packages/core/src/page/absolute-url.ts`: fix the disable comment
rule name so `no-control-regex` is suppressed (the regex
intentionally matches control chars).
- `lunaria.config.ts`: convert block-form disable to next-line form
with the new rule path.
Finally, four newer rules are disabled at the repo level:
`no-underscore-dangle` (Portable Text uses `_type`/`_key` by spec),
`typescript/consistent-return`, `typescript/no-unnecessary-type-conversion`,
`typescript/no-unnecessary-type-parameters`, and
`typescript/no-useless-default-assignment` (the rule errors out under
`strict: false`, which the test plugins use deliberately). These can
be re-enabled in follow-up PRs once their hits are triaged.
* Bump kysely to 0.29.0, fix 3 CVEs & imports
Co-authored-by: ascorbic <ascorbic@users.noreply.github.com>
* fix(cloudflare): adapt D1Introspector to kysely 0.29 type changes
kysely 0.29 made two changes that broke our D1Introspector:
1. The 'DatabaseMetadata' type and getMetadata() method were removed from
the DatabaseIntrospector interface. Drop the unused override (nothing in
the workspace called it).
2. 'TableMetadata' now requires an 'isForeign: boolean' field. D1/SQLite
has no concept of foreign tables (FDW), so emit 'isForeign: false' for
every table.
---------
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: ascorbic <ascorbic@users.noreply.github.com>
- catalog typescript ^5.9.3 -> ^6.0.3 (current stable)
- pin @typescript/native-preview to 7.0.0-dev.20260421.2 (beta) to stop
silent compiler drift from the floating ^7.0.0-dev range
- runtime.ts: key stored config on a Symbol.for registry entry instead of
a typed globalThis var, removing the TS2403 dual-load (dist+src) error
and matching the existing isolate-singleton pattern
- auth/create-emdash: add explicit types:[node]; the catalog bump's
lockfile re-resolution stopped implicit @types/node auto-inclusion
Astro bundles its own Zod and re-exports it as 'astro/zod'. Trusted
plugins like @emdash-cms/plugin-forms import their route schemas via
'astro/zod', then pass those schemas to definePlugin() in core. With
emdash's 'zod: ^4.3.5' resolving independently of Astro's caret,
pnpm kept two Zod 4 patches in the tree (e.g. 4.3.6 alongside 4.4.1).
Zod 4 embeds its semver in the type system, so two patches of Zod 4
are not assignable to each other. The forms plugin's route schemas
(ZodObject<..., $strip>) were rejected by PluginRoute<TInput>['input']
(ZodType<unknown, unknown, $ZodTypeInternals<unknown, unknown>>) with
'Type "3" is not assignable to type "4"' on the internal version
field. The native definePlugin overload silently failed, TS fell
through to the StandardPluginDefinition overload, and reported a
misleading 'id does not exist' error -- masking 8 cascading errors.
Catalog-pinning Zod forces a single workspace-wide instance and
restores normal overload resolution. No code changes needed in core
or plugins/forms.
Also adds a pnpm-workspace.yaml comment explaining the gotcha so the
next person doesn't bump emdash's pin past Astro's range.
* fix: support RS256 algorithm for Passkey authentication
* fix: address review feedback - forward-only migration and type safety
* style: format
* fix: address review feedback - forward-only migration, type safety, and error naming
* style: format
* fix: address review feedback - idempotent migration, type safety, and test fixes
* style: format
* fix: comprehensive review fixes - cross-dialect migration, integration test repair, and type safety
* fix: address reviewer feedback - improved migration robustness, regression tests for RS256, and polished error handling
* style: format
* fix: address maintainer feedback for RS256 passkey support
- Update CredentialTable type in core to include algorithm column
- Add robust RSA-2048 automated test for registration flow using BigInt representations
- Remove dead fallback code in Kysely adapter
- Clarify schema backwards compatibility in changeset
* style: format
---------
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
* feat(core): add centralized secrets module and emdash secrets CLI
PR 1 of secrets infrastructure work. Adds packages/core/src/config/secrets.ts
as the single source of truth for site-level cryptographic secrets, replacing
scattered import.meta.env reads at five route handlers.
- Preview HMAC secret and commenter-IP hash salt are now generated and
persisted in the options table on first need. EMDASH_PREVIEW_SECRET
and EMDASH_IP_SALT env vars are optional overrides.
- Replaces the empty-string fallback that silently disabled preview-token
verification, and the hardcoded 'emdash-ip-salt' constant that was
correlatable across installs.
- New 'emdash secrets generate' and 'emdash secrets fingerprint' CLI
commands. Generates versioned 'emdash_enc_v1_*' keys with canonical
base64url enforcement (kid derived from decoded bytes).
- 'emdash auth secret' kept as deprecated alias.
- create-emdash scaffolds EMDASH_ENCRYPTION_KEY into .dev.vars or .env
on project creation, idempotently.
- Template gitignores updated for .dev.vars and .env*.
- Lays groundwork for plugin-secret encryption-at-rest in a follow-up.
* docs: rewrite encryption-key copy without contributor jargon
The user-facing docs talked about 'PR 1', 'follow-up', and 'forthcoming
release' — meaningless to readers who aren't tracking the project's
internal sequencing. Reworded to plain user-facing language: the env
var is validated today, not yet used to encrypt anything, will start
being used automatically when plugin secret encryption rolls out.
* fix(core): address PR #811 review findings
- Decouple EMDASH_ENCRYPTION_KEY parse from resolveSecrets so a malformed
value can no longer 500 preview/comment hot paths for anonymous
visitors. Startup validation in EmDashRuntime.create surfaces
misconfiguration in startup logs instead.
- Enforce canonical base64url in fingerprintKey, matching
parseEncryptionKeys, so the CLI cannot print kids the runtime would
later refuse to load.
- Replace bespoke SHA-256 + hex encoding in fingerprintKeyBytes with
@oslojs/crypto and @oslojs/encoding. Add the family to the workspace
catalog and switch packages/auth + packages/core to catalog refs.
- Fix kid doc comment ("raw key string" was wrong, kid is derived from
decoded bytes).
- Rephrase "per-deployment" wording in changeset, snapshot.ts, and
themes/preview.ts to "per-database" — auto-generated values converge
across processes that share the DB.
- Drop "validates and stores" in deployment docs (we validate; we don't
store the plaintext key).
* fix(auth,core): accept multiple origins for passkey verification
Closes#799
PasskeyConfig.origin (single string) becomes origins (string[]); register
and authenticate verify against the list via .includes(). getPasskeyConfig
takes an optional allowedOrigins 4th argument; verify routes pass the
parsed contents of EMDASH_ALLOWED_ORIGINS.
This unblocks deployments where one EmDash instance is reachable under
several hostnames sharing a registrable parent (apex + preview/staging),
which WebAuthn supports via shared rpId but EmDash previously rejected
with a strict origin-equality check.
* fix(auth,core): validate multi-origin passkey config (V2 review)
Addresses review feedback on PR #800:
- Add EmDashConfig.allowedOrigins config field as a parallel to siteUrl
(maintainer ascorbic: "Can we do the same as siteUrl and allow passing
the value in config too").
- Validate the merged effective set (config + EMDASH_ALLOWED_ORIGINS) at
the runtime chokepoint in 5 verify routes. Validation throws loud,
source-attributed errors covering: Rule A (allowedOrigins requires
siteUrl), Rule B (each origin must be the same hostname as siteUrl or a
subdomain), and shape rules (parseable URL, http/https only, no
trailing-dot hostname, no empty labels). Reject IP-literal siteUrl and
trailing-dot siteUrl when allowedOrigins is non-empty (multi-origin
rpId requires a real domain).
- Validate config.allowedOrigins shape at integration init; defer
siteUrl-dependent rules to runtime when config.siteUrl is absent
(EMDASH_SITE_URL may supply it post-build per existing siteUrl pattern
in integration/index.ts).
- Throw loud on invalid EMDASH_ALLOWED_ORIGINS entries instead of silent
skip; an allowlist that drops typos masks real config errors.
- Rename internal _resetEnvSiteUrlCache to _resetEnvCache (it now resets
both site-url and allowed-origins caches).
- Fix the docstring on getEnvAllowedOrigins that incorrectly claimed
process.env was unavailable on Workers (it is, with nodejs_compat).
- Add focused register.test.ts off-list-origin test to lock in
origin-check parity with authenticate.ts.
* format
---------
Co-authored-by: Matt Kane <mkane@cloudflare.com>
Co-authored-by: Matt Kane <m@mk.gg>
* ci: release
* chore: restore original PR/author attribution in CHANGELOGs
* style: format
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <m@mk.gg>
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
* revert: undo accidental 1.0.0 release; prevent recurrence
Reverts 46f3acb (ci: release #759), which bumped all 8 publishable
packages to 1.0.0. Restores 0.7.x versions and pending changesets so
the next release lands at 0.8.0.
Root cause: peer-dep cycle between `emdash` and `@emdash-cms/auth-atproto`,
both using `workspace:*`. Changesets resolves `workspace:*` to the exact
current version for semver checks, so a minor bump on either side falls
outside the range and escalates the cycle's other side to major. Other
plugins use `workspace:>=X.Y.Z` and don't trip this — switching both
ends of the cycle to the same form fixes the escalation.
The existing workflow guard greps `pnpm changeset status` for "bumped
at major", which only runs at PR-creation time. Once the version PR is
merged, no changesets are pending and the grep returns nothing — so
the publish step proceeded with already-bumped 1.0.0 versions.
Replaces it with a package.json scan that fails on any non-private 1.x
version. Wired into both `release.yml` (publish-time) and `ci.yml`
(every PR, including the auto-generated Version Packages PR).
Also adds .claude/* to .gitignore (mirroring the .opencode pattern)
so local agent state doesn't accidentally land in commits.
* style: format
* chore: update lockfile for workspace range changes
---------
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
* feat: pluggable auth providers — add AT Protocol, refactor GitHub/Google
Introduces a pluggable auth provider system and uses it to add AT Protocol
authentication as the first plugin-based provider. GitHub and Google OAuth
are refactored from hardcoded buttons into the same provider interface.
- AuthProviderDescriptor interface with admin UI, routes, and public routes
- virtual:emdash/auth-providers Vite module for distributing provider components
- Shared findOrCreateOAuthUser() in @emdash-cms/auth for consistent signup gating
- AT Protocol auth via @atcute/oauth-node-client with PKCE (public client)
- allowedDIDs and allowedHandles config with independent handle verification
via DNS-over-HTTPS + HTTP well-known (never trusts PDS handle claims)
- Default role for new signups changed to Subscriber
- First user becomes Admin during setup regardless of provider
* fix: address PR review — TOCTOU race, ensureTable memoization
- Replace countUsers() with setup_complete option flag check in both
GitHub/Google and ATProto OAuth callbacks to prevent concurrent
callbacks from both claiming first-user admin role
- Memoize ensureTable() in db-store.ts with a module-level boolean
so CREATE TABLE IF NOT EXISTS only runs once per process
* style: format
* fix: address PR review — split auth-atproto package, add provider storage, update terminology
Addresses all 10 review comments on PR #398:
- Split auth provider into @emdash-cms/auth-atproto (npm-installable),
keep syndication plugin in @emdash-cms/plugin-atproto (marketplace)
- Add `storage` field to AuthProviderDescriptor, reuse plugin storage
infrastructure instead of manual SQL table creation
- Rename "AT Protocol" → "Atmosphere", remove "PDS" from user-facing strings
- Forbid self-signup when no allowlists configured (except first admin)
- Fix core callback.ts import to use #db alias
- Fix env.d.ts to reference emdash/locals package
* fix: CI failures — typecheck, lint, bundle, and i18n catalog
- Fix auth-atproto typecheck by aligning tsconfig with core's settings
(noUncheckedIndexedAccess, lib targets) and adding explicit return type
to storage.ts to avoid cross-package type portability issue
- Add @types/react to core devDependencies for auth/types.ts React refs
- Fix plugin bundle CLI: clean stale temp dirs before bundling, and
expand TS_EXT_RE to strip .mjs/.js/.cjs extensions from dist entries
- Fix floating promise lint error in InlinePortableTextEditor
- Regenerate Lingui catalog to include interpolated "Sign in with {0}"
* fix: update tests and plugin config for pluggable auth changes
- Fix atproto plugin: add build step, tsdown dep, and point exports to
dist files so plugin validation passes
- Update LoginPage browser test to mock fetchAuthMode instead of the
removed fetchManifest
- Update SetupWizard browser test: step label changed from "Passkey"
to "Sign In"
- Update E2E tests: passkey step text changed from "Set up your passkey"
to "Choose how to sign in"
* fix(i18n): wrap missing "Back to login" string with Lingui t tag
* fix: address PR review — remove singleton, use getPublicOrigin
Remove module-scope singleton from OAuth client (unsafe on Workers
where module vars persist between requests). Construct per-request.
Replace url.origin with getPublicOrigin() in all atproto routes so
the correct origin is used behind reverse proxies. Export
getPublicOrigin from route-utils. Narrow the EmDashConfig import in
public-url.ts to an inline interface to avoid pulling the full core
type tree into auth-atproto's typecheck.
* fix(test): add missing virtual:emdash/config mock in mcp-discovery-post test
The test was missing a mock for the virtual:emdash/config module
imported by auth middleware, causing a module resolution error.
* fix(auth-atproto): suppress no-unsafe-type-assertion lint warnings
Add eslint-disable comments for type assertions required by the
@atcute SDK's type signatures and Astro's opaque locals types.
* style: format
* fix: address PR review — use Kumo components, rename label to Atmosphere
- Replace raw HTML form elements with Kumo Input, Button, and LinkButton
in atproto admin components
- Update GitHub and Google LoginButtons to use Kumo LinkButton
- Rename auth provider label from "AT Protocol" to "Atmosphere"
- Improve oauth-client.ts loopback comment to cite RFC 8252 §8.3
- Remove broken client-side localhost→127.0.0.1 redirect that reset
the setup wizard; server-side normalization handles this correctly
* fix(auth-atproto): use Button not LinkButton for LoginButton
Per ascorbic review: LinkButton is for actual links; LoginButton
expands the inline sign-in form on click, so it's a Button.
* fix(auth): restore findOrCreateOAuthUser export after upstream merge
Auto-merge with upstream/main reverted our pluggable-auth refactor —
findOrCreateOAuthUser and the CanSelfSignup type were silently removed
from packages/auth/src/index.ts, and consumer.ts was rolled back to the
private findOrCreateUser shape. auth-atproto's callback route depends on
these exports, so typecheck was failing.
Restored both files from branch tip 4faa43b. The User-Agent fix from
upstream commit 1a93d51 is preserved (it was already on the branch).
---------
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>