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/docs/README.md b/docs/README.md index 35b6eb6a5..03896aa44 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,8 +8,28 @@ 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, 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 +``` + +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 93fb33915..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", }, @@ -171,6 +172,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", @@ -237,5 +249,5 @@ export default defineConfig({ }), ], - adapter: cloudflare(), + adapter: cloudflare({ remoteBindings: false, prerenderEnvironment: "node" }), }); 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. 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 a86b4a8d5..f1077da61 100644 --- a/docs/src/worker.ts +++ b/docs/src/worker.ts @@ -13,6 +13,37 @@ 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 +69,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, + }, +} 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/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/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/components/SeoPanel.tsx b/packages/admin/src/components/SeoPanel.tsx index 4eda843bf..84725e87d 100644 --- a/packages/admin/src/components/SeoPanel.tsx +++ b/packages/admin/src/components/SeoPanel.tsx @@ -80,8 +80,13 @@ export function SeoPanel({ contentKey, seo, onChange }: SeoPanelProps) { }, []); const flushPendingDraft = React.useCallback(() => { + const nextDraft = currentDraftRef.current; + const nextSnapshot = serializeDraft(nextDraft); clearPendingTextFlush(); - emitChange(currentDraftRef.current); + if (nextSnapshot === lastEmittedSnapshotRef.current) { + return; + } + emitChange(nextDraft); }, [clearPendingTextFlush, emitChange]); React.useEffect(() => { 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..a58291e10 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.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 }, + ); }); }); 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/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/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/**/*"], 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/**" + ] +}