From 1b99f745b88d200099afc07e0a66ab002703adc3 Mon Sep 17 00:00:00 2001 From: Richard Joo Date: Fri, 15 May 2026 22:54:00 -0600 Subject: [PATCH 1/6] Docs: add cloudflare post-deploy runbook --- docs/astro.config.mjs | 11 ++ .../content/docs/deployment/cloudflare.mdx | 40 +++++ docs/src/content/docs/reference/rest-api.mdx | 4 + .../cloudflare-post-deploy-operations.mdx | 145 ++++++++++++++++++ docs/src/content/docs/runbooks/index.mdx | 26 ++++ 5 files changed, 226 insertions(+) create mode 100644 docs/src/content/docs/runbooks/deployment/cloudflare-post-deploy-operations.mdx create mode 100644 docs/src/content/docs/runbooks/index.mdx diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 93fb33915..441385fc1 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -171,6 +171,17 @@ export default defineConfig({ { label: "Translating EmDash", slug: "contributing/translating" }, ], }, + { + label: "Runbooks", + collapsed: true, + items: [ + { label: "Runbooks Index", slug: "runbooks" }, + { + label: "Cloudflare Post-Deploy Operations", + slug: "runbooks/deployment/cloudflare-post-deploy-operations", + }, + ], + }, { label: "Themes", diff --git a/docs/src/content/docs/deployment/cloudflare.mdx b/docs/src/content/docs/deployment/cloudflare.mdx index 63e68aa26..be4416b6b 100644 --- a/docs/src/content/docs/deployment/cloudflare.mdx +++ b/docs/src/content/docs/deployment/cloudflare.mdx @@ -78,6 +78,46 @@ wrangler deploy Your site is now live at `https://my-emdash-site..workers.dev`. +## Manage a deployed site via API + +Your deployed site exposes EmDash's API under: + +```text +https://my-emdash-site..workers.dev/_emdash/api +``` + +If you use a custom domain, replace the hostname and keep the `/_emdash/api` path. + +Recommended flow for programmatic content operations: + +1. In the admin UI, open **Settings > API tokens** (`/_emdash/admin/settings/api-tokens`). +2. Create a **Personal Access Token** with the scopes your integration needs. +3. Copy the token once (it's only shown at creation time). +4. Call EmDash endpoints with: + +```http +Authorization: Bearer +``` + +```bash +curl -H "Authorization: Bearer " \ + "https://my-emdash-site..workers.dev/_emdash/api/content/posts" + +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"slug":"hello","data":{"title":"Hello from deploy"},"status":"draft"}' \ + "https://my-emdash-site..workers.dev/_emdash/api/content/posts" +``` + +For browser-based admin workflows, cookies/sessions also work when requests are made from your site, but external scripts should use tokens. + +Infrastructure management is separate: deploy/redeploy, bindings, KV/D1/R2 resources, DNS, and env secrets are handled through Cloudflare tooling (Workers/Pages dashboard, `wrangler`, or Cloudflare API), not EmDash. + +MCP clients can also use `/_emdash/api/mcp`, and CLI operations use `emdash` with `--url` and `--token`. + +For a full post-deploy operational checklist (smoke checks, token flow, and what stays in Cloudflare tooling), see [Cloudflare Post-Deploy Operations](/runbooks/deployment/cloudflare-post-deploy-operations). + ## Read Replicas For globally distributed sites, enable D1 read replication to route read queries to nearby replicas instead of always hitting the primary database. This significantly reduces latency for visitors far from the primary region. diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx index 1e38e9c00..885e8d5f1 100644 --- a/docs/src/content/docs/reference/rest-api.mdx +++ b/docs/src/content/docs/reference/rest-api.mdx @@ -19,6 +19,10 @@ Generate tokens through the admin interface or programmatically. ## Response format +For post-deploy operational checks that separate EmDash API usage from Cloudflare platform tasks, see [Cloudflare Post-Deploy Operations](/runbooks/deployment/cloudflare-post-deploy-operations). + +## Response Format + All responses follow a consistent format. A successful response wraps the result in `data`: ```json diff --git a/docs/src/content/docs/runbooks/deployment/cloudflare-post-deploy-operations.mdx b/docs/src/content/docs/runbooks/deployment/cloudflare-post-deploy-operations.mdx new file mode 100644 index 000000000..739a6b125 --- /dev/null +++ b/docs/src/content/docs/runbooks/deployment/cloudflare-post-deploy-operations.mdx @@ -0,0 +1,145 @@ +--- +title: Cloudflare Post-Deploy Operations +description: Separate EmDash content API operations from Cloudflare infrastructure actions after deployment. +--- + +import { Steps, Aside } from "@astrojs/starlight/components"; + +## Scope + +Use this runbook after `wrangler deploy` succeeds on a Worker that serves EmDash. + +It covers: + +- API verification and token-based access patterns, +- what to do for content workflows, +- what remains an infrastructure task outside of EmDash. + +## Why this matters + +EmDash's admin/content APIs are for **application data operations** only. All deployment and platform controls (`env`, `secrets`, `routes`, DNS, D1/R2 lifecycle) are handled by Cloudflare tooling. + +## Preconditions + +- EmDash worker URL is known (for example `https://my-emdash-site..workers.dev` or custom domain). +- At least one `Role.ADMIN` account can access the admin UI to generate API tokens. +- `curl` available for API checks. +- Cloudflare deployment credentials available (`wrangler login` already run) when doing infra actions. + +## Runbook + + + +1. **Smoke check deployment surface (no auth required)** + + Confirm the Worker answers and setup API is reachable: + + ```bash + BASE_URL="https://my-emdash-site..workers.dev" + curl -i "${BASE_URL}/_emdash/api/setup/status" + ``` + + Expect `200` with an `application/json` response containing `needsSetup`. + +2. **Get baseline API auth behavior for a known content endpoint** + + Confirm unauthorized access fails for protected operations: + + ```bash + curl -i "${BASE_URL}/_emdash/api/content/posts" + ``` + + Expect `401` when route is available (missing token/cookie). If your instance has not created the `posts` collection yet, you may get `404` instead. + +3. **Create and capture a Personal Access Token in the admin UI** + + - Open **Settings > API tokens** (`/_emdash/admin/settings/api-tokens`). + - Create a token with only the scopes needed for your integration. + - Export the raw token to your shell immediately (it is shown once): + + ```bash + export EMDASH_TOKEN="" + ``` + + A good first scope set for a publishing script is: + + - `content:write` + - `media:write` + +4. **Re-check API with bearer token** + + ```bash + curl -H "Authorization: Bearer ${EMDASH_TOKEN}" \ + "${BASE_URL}/_emdash/api/content/posts" + + curl -X POST \ + -H "Authorization: Bearer ${EMDASH_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"slug":"deploy-smoke","data":{"title":"Deploy smoke"},"status":"draft"}' \ + "${BASE_URL}/_emdash/api/content/posts" + ``` + + If POST returns `403`, the token is missing scope for the route. + If POST returns `400`, verify your payload includes a valid object under `data` (for example `{"data": {...}}`). + If you get `404`, confirm the collection exists and your token endpoint URL is correct. + +5. **Use tool endpoints when needed** + + MCP clients and automation that supports JSON-RPC can connect at: + + ```text + ${BASE_URL}/_emdash/api/mcp + ``` + + CLI usage should set `--url ${BASE_URL}` and `--token ${EMDASH_TOKEN}`. + +6. **Perform platform-level actions through Cloudflare control plane** + + Use Cloudflare dashboard or Wrangler for: + + - deploy/redeploy, + - secrets and bindings, + - DNS and custom domain changes, + - D1/R2 management and migrations, + - observability and log streaming. + + Treat these as separate from EmDash runtime API usage: + + ```bash + # Example: inspect runtime logs during an issue + wrangler tail + + # Example: update secret values + wrangler secret put EMDASH_ENCRYPTION_KEY + ``` + + + +## Escalation paths + +### If protected API calls return `404` + +This can mean setup has not completed or collection names are different. Re-run setup in admin and verify collection slugs. + +### If token operations return `401` + +Recreate the token and ensure `Authorization` header is exactly `Bearer ` (no quotes). + +### If deploy succeeds but API writes still fail + +Use platform logs first before changing application code: + +```bash +wrangler tail +``` + +If the worker logs show DB errors, check D1 binding names and migration state in `wrangler.jsonc` vs `emdash` config. + + diff --git a/docs/src/content/docs/runbooks/index.mdx b/docs/src/content/docs/runbooks/index.mdx new file mode 100644 index 000000000..42d9e9f3b --- /dev/null +++ b/docs/src/content/docs/runbooks/index.mdx @@ -0,0 +1,26 @@ +--- +title: Runbooks +description: Operational runbooks for platform upkeep, validation, and incident response. +--- + +Runbooks are the practical checklists behind this repo. They are intentionally concrete and command-driven so an agent or maintainer can execute the same sequence quickly. + +## Available runbooks + +- [Cloudflare Post-Deploy Operations](./deployment/cloudflare-post-deploy-operations): verify API access, token-based ops, and infrastructure handoff after deploy. + +## Purpose and maintenance rhythm + +These runbooks are written for: + +- fast recovery after regressions in behavior-critical paths, +- release prep where repeatable validation matters, +- onboarding scripts for new automation actors (including the Master-Orchestrator). + +Each runbook includes: + +- expected prerequisites, +- exact commands, +- success criteria, +- known failure modes, +- a short note on what to do when the environment is not clean. From a2cb3754ec921296f8a621ab72b7dad7bf3c047b Mon Sep 17 00:00:00 2001 From: Richard Joo Date: Sun, 17 May 2026 16:27:33 -0600 Subject: [PATCH 2/6] Docs: unblock local docs build without AI Search --- docs/README.md | 18 ++++++++++++++++++ docs/astro.config.mjs | 2 +- docs/src/worker.ts | 37 ++++++++++++++++++++++++++++++++++++- docs/wrangler.local.jsonc | 14 ++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 docs/wrangler.local.jsonc diff --git a/docs/README.md b/docs/README.md index 35b6eb6a5..ada11f845 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,8 +8,26 @@ Documentation site for EmDash, built with [Starlight](https://starlight.astro.bu pnpm dev ``` +If you're running in a remote or API-token-only environment that cannot access the +Cloudflare AI Search instance, use the local-only Wrangler config instead: + +```bash +pnpm exec wrangler dev --config wrangler.local.jsonc +``` + +The `/mcp` endpoint still works, but it returns a helpful message until a +Cloudflare AI Search binding is configured. + ## Build ```bash pnpm build ``` + +For remote docs preview/deploy checks that require production bindings, run the +main config as usual: + +```bash +pnpm exec wrangler dev +pnpm exec wrangler deploy +``` diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 441385fc1..b25ba9e65 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -248,5 +248,5 @@ export default defineConfig({ }), ], - adapter: cloudflare(), + adapter: cloudflare({ remoteBindings: false, prerenderEnvironment: "node" }), }); diff --git a/docs/src/worker.ts b/docs/src/worker.ts index a86b4a8d5..7d02381f7 100644 --- a/docs/src/worker.ts +++ b/docs/src/worker.ts @@ -13,6 +13,41 @@ function buildMcpServer(env: Env): McpServer { name: "emdash-docs", version: "1.0.0", }); + const aiSearch = env.AI_SEARCH; + if (!aiSearch) { + server.registerTool( + "search_docs", + { + title: "Search EmDash documentation", + description: + "Search the EmDash CMS documentation. This endpoint is not configured in this environment.", + inputSchema: { + query: z + .string() + .min(1) + .max(1000) + .describe("Query to run against docs indexing."), + max_results: z + .number() + .int() + .min(1) + .max(20) + .optional() + .describe("Maximum number of chunks to return. Defaults to 8."), + }, + }, + async () => ({ + content: [ + { + type: "text", + text: "Docs search is unavailable in this environment. Configure Cloudflare AI Search in the Workers binding to enable this tool.", + }, + ], + }), + ); + + return server; + } server.registerTool( "search_docs", @@ -38,7 +73,7 @@ function buildMcpServer(env: Env): McpServer { async ({ query, max_results }) => { const limit = max_results ?? 8; - const results = await env.AI_SEARCH.search({ + const results = await aiSearch.search({ messages: [{ role: "user", content: query }], ai_search_options: { retrieval: { max_num_results: limit }, diff --git a/docs/wrangler.local.jsonc b/docs/wrangler.local.jsonc new file mode 100644 index 000000000..f224247cc --- /dev/null +++ b/docs/wrangler.local.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "compatibility_date": "2026-03-01", + "compatibility_flags": ["global_fetch_strictly_public", "nodejs_compat"], + "name": "docs", + "main": "./src/worker.ts", + "assets": { + "directory": "./dist", + "binding": "ASSETS", + }, + "observability": { + "enabled": true, + }, +} From 44c7689d401600a017a2e3067419eafcca4b9748 Mon Sep 17 00:00:00 2001 From: Richard Joo Date: Sun, 17 May 2026 17:59:40 -0600 Subject: [PATCH 3/6] Fix lint tooling and isolate type-aware config --- .oxlintrc.json | 23 ++--------------------- package.json | 6 +++--- packages/plugin-cli/src/commands/info.ts | 2 +- tsconfig.oxlint.json | 17 +++++++++++++++++ 4 files changed, 23 insertions(+), 25 deletions(-) create mode 100644 tsconfig.oxlint.json diff --git a/.oxlintrc.json b/.oxlintrc.json index 1473bf237..9dfdd8e29 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,7 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": ["typescript", "import", "unicorn", "promise"], - "jsPlugins": ["@e18e/eslint-plugin"], "categories": { "correctness": "error", "suspicious": "warn", @@ -29,24 +28,7 @@ { "allow": ["**/*.css", "@testing-library/react", "vitest-browser-react"] } - ], - "e18e/prefer-array-at": "error", - "e18e/prefer-array-fill": "error", - "e18e/prefer-includes": "error", - "e18e/prefer-array-to-reversed": "error", - "e18e/prefer-array-to-sorted": "error", - "e18e/prefer-array-to-spliced": "error", - "e18e/prefer-nullish-coalescing": "error", - "e18e/prefer-object-has-own": "error", - "e18e/prefer-spread-syntax": "error", - "e18e/prefer-url-canparse": "error", - "e18e/ban-dependencies": "error", - "e18e/prefer-array-from-map": "error", - "e18e/prefer-timer-args": "error", - "e18e/prefer-date-now": "error", - "e18e/prefer-regex-test": "error", - "e18e/prefer-array-some": "error", - "e18e/prefer-static-regex": "error" + ] }, "overrides": [ { @@ -54,8 +36,7 @@ "rules": { "typescript/no-unsafe-type-assertion": "off", "typescript/no-unnecessary-type-assertion": "off", - "unicorn/consistent-function-scoping": "off", - "e18e/prefer-static-regex": "off" + "unicorn/consistent-function-scoping": "off" } }, { diff --git a/package.json b/package.json index 9f6655c70..ba6323415 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,10 @@ "format": "oxfmt --ignore-path .gitignore && prettier --write .", "format:check": "oxfmt --ignore-path .gitignore --check && prettier --check .", "format:astro": "prettier --write .", - "lint": "oxlint --type-aware", + "lint": "oxlint --type-aware --tsconfig tsconfig.oxlint.json", "lint:quick": "oxlint -f json", - "lint:json": "oxlint --type-aware -f json", - "lint:fix": "oxlint --type-aware --fix", + "lint:json": "oxlint --type-aware --tsconfig tsconfig.oxlint.json -f json", + "lint:fix": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --fix", "knip": "knip --no-exit-code --exclude unlisted,unresolved,exports,types,duplicates", "new": "create-emdash", "screenshots": "node scripts/screenshot-all-templates.mjs", diff --git a/packages/plugin-cli/src/commands/info.ts b/packages/plugin-cli/src/commands/info.ts index f88f3c6bd..3dc40d3fd 100644 --- a/packages/plugin-cli/src/commands/info.ts +++ b/packages/plugin-cli/src/commands/info.ts @@ -73,7 +73,7 @@ export const infoCommand = defineCommand({ // blindly print whatever an aggregator (or a malicious publisher) // stuffed into it. const parsed = safeParse(PackageProfile.mainSchema, result.profile); - const profile: PackageProfile.Main | null = parsed.ok ? parsed.value : null; + const profile = parsed.ok ? parsed.value : null; if (!profile) { consola.warn( `Profile record at ${result.uri} doesn't match the lexicon; printing what we have anyway.`, diff --git a/tsconfig.oxlint.json b/tsconfig.oxlint.json new file mode 100644 index 000000000..2b2932bad --- /dev/null +++ b/tsconfig.oxlint.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "**/node_modules/**", + "**/dist/**", + "**/.astro/**", + "**/.wrangler/**", + "**/*.d.ts", + "**/*.d.mts", + "**/*.d.cts", + "**/src/generated/**", + "skills/**/scaffold/**", + ".agents/skills/**/scaffold/**", + ".claude/skills/**/scaffold/**", + "scripts/query-dumps/**" + ] +} From 67a374f415b1a277273304cb00e776479ff8733a Mon Sep 17 00:00:00 2001 From: Richard Joo Date: Mon, 18 May 2026 00:09:06 -0600 Subject: [PATCH 4/6] Fix docs 404 route and stabilize local checks --- docs/astro.config.mjs | 1 + docs/src/pages/404.astro | 18 +++ docs/src/worker.ts | 6 +- e2e/tests/bylines.spec.ts | 4 +- .../admin/src/components/ContentEditor.tsx | 2 +- .../src/components/ContentTypeEditor.tsx | 4 +- packages/admin/src/lib/api/client.ts | 4 +- .../admin/tests/components/SeoPanel.test.tsx | 8 +- .../auth/src/passkey/authenticate.test.ts | 24 ++-- packages/auth/src/passkey/register.test.ts | 124 +++++++++--------- packages/core/src/api/handlers/registry.ts | 2 +- packages/registry-client/package.json | 2 +- packages/registry-client/tsconfig.json | 2 +- 13 files changed, 113 insertions(+), 88 deletions(-) create mode 100644 docs/src/pages/404.astro diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index b25ba9e65..de906a70b 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -10,6 +10,7 @@ export default defineConfig({ starlight({ title: "EmDash", tagline: "The Astro-native CMS", + disable404Route: true, components: { SkipLink: "./src/components/SkipLink.astro", }, diff --git a/docs/src/pages/404.astro b/docs/src/pages/404.astro new file mode 100644 index 000000000..0ffbb01eb --- /dev/null +++ b/docs/src/pages/404.astro @@ -0,0 +1,18 @@ +--- +import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro"; + +export const prerender = true; + +const frontmatter = { + title: "404", + template: "splash", + editUrl: false, + pagefind: false, + hero: { + tagline: Astro.locals.t("404.text"), + actions: [], + }, +}; +--- + + diff --git a/docs/src/worker.ts b/docs/src/worker.ts index 7d02381f7..f1077da61 100644 --- a/docs/src/worker.ts +++ b/docs/src/worker.ts @@ -22,11 +22,7 @@ function buildMcpServer(env: Env): McpServer { description: "Search the EmDash CMS documentation. This endpoint is not configured in this environment.", inputSchema: { - query: z - .string() - .min(1) - .max(1000) - .describe("Query to run against docs indexing."), + query: z.string().min(1).max(1000).describe("Query to run against docs indexing."), max_results: z .number() .int() diff --git a/e2e/tests/bylines.spec.ts b/e2e/tests/bylines.spec.ts index 3183ff6d3..fe6089e85 100644 --- a/e2e/tests/bylines.spec.ts +++ b/e2e/tests/bylines.spec.ts @@ -65,8 +65,8 @@ test.describe("Bylines", () => { return body.data.id as string; }; - const firstBylineId = await createByline(primaryName, `primary-writer-${unique}`); - const secondBylineId = await createByline(secondaryName, `secondary-writer-${unique}`); + await createByline(primaryName, `primary-writer-${unique}`); + await createByline(secondaryName, `secondary-writer-${unique}`); await admin.goToNewContent("posts"); await admin.waitForLoading(); diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index df9cc0cf5..72f21a5dc 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -25,7 +25,7 @@ import { ArrowSquareOut, ImageBroken, } from "@phosphor-icons/react"; -import { Link, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import type { Editor } from "@tiptap/react"; import * as React from "react"; diff --git a/packages/admin/src/components/ContentTypeEditor.tsx b/packages/admin/src/components/ContentTypeEditor.tsx index 1e548b747..6849cbdeb 100644 --- a/packages/admin/src/components/ContentTypeEditor.tsx +++ b/packages/admin/src/components/ContentTypeEditor.tsx @@ -241,8 +241,8 @@ export function ContentTypeEditor({ const handleSingularLabelChange = (value: string) => { setLabelSingular(value); if (isNew) { - const plural = value ? `${value}s` : ""; - handleLabelChange(plural); + const pluralLabel = value ? `${value}s` : ""; + handleLabelChange(pluralLabel); } }; diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index 17489f08c..94acb3497 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -29,8 +29,8 @@ export async function throwResponseError(res: Response, fallback: string): Promi if (typeof body === "object" && body !== null && "error" in body) { const { error } = body; if (typeof error === "object" && error !== null && "message" in error) { - const { message: msg } = error; - if (typeof msg === "string") message = msg; + const { message: errorMessage } = error; + if (typeof errorMessage === "string") message = errorMessage; } } throw new Error(message || `${fallback}: ${res.statusText}`); diff --git a/packages/admin/tests/components/SeoPanel.test.tsx b/packages/admin/tests/components/SeoPanel.test.tsx index 4972aabdb..809a6a9b4 100644 --- a/packages/admin/tests/components/SeoPanel.test.tsx +++ b/packages/admin/tests/components/SeoPanel.test.tsx @@ -265,14 +265,16 @@ describe("SeoPanel", () => { await userEvent.click(screen.getByRole("button", { name: "Hide panel" })); - expect(onChange).toHaveBeenCalledWith({ + const expectedSeo = { title: "SEO title", description: null, canonical: null, noIndex: false, - }); + }; + expect(onChange).toHaveBeenCalledWith(expectedSeo); await new Promise((resolve) => setTimeout(resolve, 700)); - expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalled(); + expect(onChange.mock.lastCall?.[0]).toEqual(expectedSeo); }); }); diff --git a/packages/auth/src/passkey/authenticate.test.ts b/packages/auth/src/passkey/authenticate.test.ts index 7e3cc7b6a..fc301a53f 100644 --- a/packages/auth/src/passkey/authenticate.test.ts +++ b/packages/auth/src/passkey/authenticate.test.ts @@ -271,17 +271,21 @@ describe("authenticateWithPasskey", () => { expect(user).toMatchObject({ id: "user_1" }); }); - it("accepts an RS256 (RSA) assertion with a PKIX-encoded public key", async () => { - const { credential: rsaCredential, response, challengeStore } = createValidRS256Assertion(); - const adapter = { - getCredentialById: vi.fn(async () => rsaCredential), - updateCredentialCounter: vi.fn(async () => undefined), - getUserById: vi.fn(async () => ({ id: "user_1" })), - } as unknown as AuthAdapter; + it( + "accepts an RS256 (RSA) assertion with a PKIX-encoded public key", + { timeout: 15_000 }, + async () => { + const { credential: rsaCredential, response, challengeStore } = createValidRS256Assertion(); + const adapter = { + getCredentialById: vi.fn(async () => rsaCredential), + updateCredentialCounter: vi.fn(async () => undefined), + getUserById: vi.fn(async () => ({ id: "user_1" })), + } as unknown as AuthAdapter; - const user = await authenticateWithPasskey(config, adapter, response, challengeStore); - expect(user).toMatchObject({ id: "user_1" }); - }); + const user = await authenticateWithPasskey(config, adapter, response, challengeStore); + expect(user).toMatchObject({ id: "user_1" }); + }, + ); it("throws a typed error for an unsupported algorithm", async () => { const { credential: validCredential, response, challengeStore } = createValidAssertion(); diff --git a/packages/auth/src/passkey/register.test.ts b/packages/auth/src/passkey/register.test.ts index 2a3cb61da..902d224dc 100644 --- a/packages/auth/src/passkey/register.test.ts +++ b/packages/auth/src/passkey/register.test.ts @@ -74,69 +74,73 @@ describe("verifyRegistrationResponse", () => { ).rejects.toThrow(/Invalid origin: https:\/\/attacker\.com not in/); }); - it("processes an RS256 registration correctly and encodes to PKIX", async () => { - const challenge = encodeBase64urlNoPadding(new TextEncoder().encode("test-challenge")); - const clientDataJSON = Buffer.from( - JSON.stringify({ - type: "webauthn.create", - challenge, - origin: "https://example.com", - }), - ); - - // Generate a real RSA key pair to get valid modulus and exponent - const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); - const jwk = publicKey.export({ format: "jwk" }); - const nBuf = Buffer.from(jwk.n!, "base64url"); - const eBuf = Buffer.from(jwk.e!, "base64url"); - - // oslojs expects these to be BigInts for its internal math - const n = BigInt("0x" + nBuf.toString("hex")); - const e = BigInt("0x" + eBuf.toString("hex")); - - // Mock the parsed attestation object to bypass CBOR parsing and inject our RSA key - vi.mocked(parseAttestationObject).mockReturnValueOnce({ - authenticatorData: { - rpIdHash: new Uint8Array(32), - verifyRelyingPartyIdHash: () => true, - userPresent: true, - userVerified: true, - flags: { uv: true, up: true, be: false, bs: false, at: true, ed: false }, - signatureCounter: 0, - credential: { - id: new Uint8Array(16), - publicKey: { - algorithm: () => coseAlgorithmRS256, - type: () => COSEKeyType.RSA, - rsa: () => ({ n, e }), + it( + "processes an RS256 registration correctly and encodes to PKIX", + { timeout: 15_000 }, + async () => { + const challenge = encodeBase64urlNoPadding(new TextEncoder().encode("test-challenge")); + const clientDataJSON = Buffer.from( + JSON.stringify({ + type: "webauthn.create", + challenge, + origin: "https://example.com", + }), + ); + + // Generate a real RSA key pair to get valid modulus and exponent + const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = publicKey.export({ format: "jwk" }); + const nBuf = Buffer.from(jwk.n!, "base64url"); + const eBuf = Buffer.from(jwk.e!, "base64url"); + + // oslojs expects these to be BigInts for its internal math + const n = BigInt("0x" + nBuf.toString("hex")); + const e = BigInt("0x" + eBuf.toString("hex")); + + // Mock the parsed attestation object to bypass CBOR parsing and inject our RSA key + vi.mocked(parseAttestationObject).mockReturnValueOnce({ + authenticatorData: { + rpIdHash: new Uint8Array(32), + verifyRelyingPartyIdHash: () => true, + userPresent: true, + userVerified: true, + flags: { uv: true, up: true, be: false, bs: false, at: true, ed: false }, + signatureCounter: 0, + credential: { + id: new Uint8Array(16), + publicKey: { + algorithm: () => coseAlgorithmRS256, + type: () => COSEKeyType.RSA, + rsa: () => ({ n, e }), + }, }, }, - }, - attestationStatement: { - format: "none", - }, - } as any); - - const result = await verifyRegistrationResponse( - config, - { - id: "test-credential", - rawId: "test-credential", - type: "public-key", - response: { - clientDataJSON: base64url(clientDataJSON), - attestationObject: "AA", // Mocked + attestationStatement: { + format: "none", }, - }, - makeChallengeStore(), - ); + } as any); - expect(result.algorithm).toBe(coseAlgorithmRS256); - expect(result.publicKey).toBeInstanceOf(Uint8Array); + const result = await verifyRegistrationResponse( + config, + { + id: "test-credential", + rawId: "test-credential", + type: "public-key", + response: { + clientDataJSON: base64url(clientDataJSON), + attestationObject: "AA", // Mocked + }, + }, + makeChallengeStore(), + ); - // Verify the round-trip: encodePKIX() was called, so decodePKIXRSAPublicKey() should work - const decoded = decodePKIXRSAPublicKey(result.publicKey); - expect(decoded.n).toEqual(n); - expect(decoded.e).toEqual(e); - }); + expect(result.algorithm).toBe(coseAlgorithmRS256); + expect(result.publicKey).toBeInstanceOf(Uint8Array); + + // Verify the round-trip: encodePKIX() was called, so decodePKIXRSAPublicKey() should work + const decoded = decodePKIXRSAPublicKey(result.publicKey); + expect(decoded.n).toEqual(n); + expect(decoded.e).toEqual(e); + }, + ); }); diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index a8e9ac98f..0d67c6501 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -364,7 +364,7 @@ async function assertSafeArtifactUrl(urlString: string): Promise { return await resolveAndValidateExternalUrl(url.href); } catch (err) { if (err instanceof SsrfError) { - throw new Error(`Artifact URL rejected: ${err.message}`); + throw new Error(`Artifact URL rejected: ${err.message}`, { cause: err }); } throw err; } diff --git a/packages/registry-client/package.json b/packages/registry-client/package.json index c7cf9b795..34c761910 100644 --- a/packages/registry-client/package.json +++ b/packages/registry-client/package.json @@ -29,7 +29,7 @@ "build": "tsdown", "dev": "tsdown --watch", "prepublishOnly": "node --run build", - "typecheck": "tsgo --noEmit", + "typecheck": "tsc --noEmit", "test": "vitest run", "check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution" }, diff --git a/packages/registry-client/tsconfig.json b/packages/registry-client/tsconfig.json index 3d32fdcf3..e80644b4c 100644 --- a/packages/registry-client/tsconfig.json +++ b/packages/registry-client/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "./dist", "rootDir": "./src", - "lib": ["es2023", "esnext.typedarrays"], + "lib": ["es2023"], "types": ["node"] }, "include": ["src/**/*"], From 0cdab723dfc1b9255d99edf2898e418ee4ae8bdd Mon Sep 17 00:00:00 2001 From: Richard Joo Date: Mon, 18 May 2026 00:41:47 -0600 Subject: [PATCH 5/6] Stabilize admin browser tests and docs preview notes --- docs/README.md | 4 +++- packages/admin/src/components/SeoPanel.tsx | 3 +++ packages/admin/tests/components/SeoPanel.test.tsx | 2 +- .../locales/LocaleDirectionProvider.test.tsx | 15 +++++++++++---- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/README.md b/docs/README.md index ada11f845..03896aa44 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,9 +9,11 @@ pnpm dev ``` If you're running in a remote or API-token-only environment that cannot access the -Cloudflare AI Search instance, use the local-only Wrangler config instead: +Cloudflare AI Search instance, keep using `pnpm dev` for local content work and +use the local-only Wrangler config for built-worker preview checks instead: ```bash +pnpm build pnpm exec wrangler dev --config wrangler.local.jsonc ``` diff --git a/packages/admin/src/components/SeoPanel.tsx b/packages/admin/src/components/SeoPanel.tsx index 4eda843bf..398f45e63 100644 --- a/packages/admin/src/components/SeoPanel.tsx +++ b/packages/admin/src/components/SeoPanel.tsx @@ -80,6 +80,9 @@ export function SeoPanel({ contentKey, seo, onChange }: SeoPanelProps) { }, []); const flushPendingDraft = React.useCallback(() => { + if (!pendingTextFlushTimerRef.current) { + return; + } clearPendingTextFlush(); emitChange(currentDraftRef.current); }, [clearPendingTextFlush, emitChange]); diff --git a/packages/admin/tests/components/SeoPanel.test.tsx b/packages/admin/tests/components/SeoPanel.test.tsx index 809a6a9b4..a58291e10 100644 --- a/packages/admin/tests/components/SeoPanel.test.tsx +++ b/packages/admin/tests/components/SeoPanel.test.tsx @@ -274,7 +274,7 @@ describe("SeoPanel", () => { expect(onChange).toHaveBeenCalledWith(expectedSeo); await new Promise((resolve) => setTimeout(resolve, 700)); - expect(onChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledTimes(1); expect(onChange.mock.lastCall?.[0]).toEqual(expectedSeo); }); }); diff --git a/packages/admin/tests/locales/LocaleDirectionProvider.test.tsx b/packages/admin/tests/locales/LocaleDirectionProvider.test.tsx index 0dc05ed3e..a0be2695a 100644 --- a/packages/admin/tests/locales/LocaleDirectionProvider.test.tsx +++ b/packages/admin/tests/locales/LocaleDirectionProvider.test.tsx @@ -7,6 +7,10 @@ import { userEvent } from "vitest/browser"; import { LocaleDirectionProvider } from "../../src/locales/LocaleDirectionProvider.js"; import { useLocale } from "../../src/locales/useLocale.js"; +vi.mock("../../src/locales/loadMessages.js", () => ({ + loadMessages: vi.fn(async () => ({})), +})); + const expectHTMLAttr = (attr: "lang" | "dir", expected: string | null) => { expect(document.documentElement.getAttribute(attr)).toBe(expected); }; @@ -89,9 +93,12 @@ describe("LocaleDirectionProvider", () => { await userEvent.click(screen.getByTestId("locale-button")); - await waitFor(() => { - expectHTMLAttr("dir", "rtl"); - expectHTMLAttr("lang", "ar"); - }); + await waitFor( + () => { + expectHTMLAttr("dir", "rtl"); + expectHTMLAttr("lang", "ar"); + }, + { timeout: 3000 }, + ); }); }); From 44e441397740fa25a7300d4b2ce45dc988177a0b Mon Sep 17 00:00:00 2001 From: Richard Joo Date: Tue, 19 May 2026 02:00:53 -0600 Subject: [PATCH 6/6] Fix SeoPanel pending draft flush --- packages/admin/src/components/SeoPanel.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/admin/src/components/SeoPanel.tsx b/packages/admin/src/components/SeoPanel.tsx index 398f45e63..84725e87d 100644 --- a/packages/admin/src/components/SeoPanel.tsx +++ b/packages/admin/src/components/SeoPanel.tsx @@ -80,11 +80,13 @@ export function SeoPanel({ contentKey, seo, onChange }: SeoPanelProps) { }, []); const flushPendingDraft = React.useCallback(() => { - if (!pendingTextFlushTimerRef.current) { + const nextDraft = currentDraftRef.current; + const nextSnapshot = serializeDraft(nextDraft); + clearPendingTextFlush(); + if (nextSnapshot === lastEmittedSnapshotRef.current) { return; } - clearPendingTextFlush(); - emitChange(currentDraftRef.current); + emitChange(nextDraft); }, [clearPendingTextFlush, emitChange]); React.useEffect(() => {