diff --git a/.github/workflows/monitor-registries.yml b/.github/workflows/monitor-registries.yml new file mode 100644 index 0000000000..c1eaf7721e --- /dev/null +++ b/.github/workflows/monitor-registries.yml @@ -0,0 +1,75 @@ +name: Monitor Registries + +on: + schedule: + - cron: "17 * * * *" + workflow_dispatch: + inputs: + mode: + description: Registry checks to run + required: true + default: auto + type: choice + options: + - auto + - hourly + - daily + - weekly + - all + +permissions: + contents: read + +concurrency: + group: registry-health-monitor + cancel-in-progress: false + +jobs: + monitor: + name: Monitor registry health + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.19.1 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: 10.33.4 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "pnpm_cache_dir=$(pnpm store path)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download previous health state + env: + BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + run: pnpm registry:health:prepare + + - name: Check registries + env: + MONITOR_MODE: ${{ inputs.mode || 'auto' }} + run: pnpm registry:health:check + + - name: Publish health snapshot + env: + BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + run: pnpm registry:health:publish diff --git a/.gitignore b/.gitignore index 29d28077f2..2193136145 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,8 @@ plans # vitest browser mode writes these only on test failure. __screenshots__ .codex-artifacts +.registry-health +.vercel .tmp* CONTEXT.md diff --git a/apps/v4/app/r/registries.json/route.test.ts b/apps/v4/app/r/registries.json/route.test.ts new file mode 100644 index 0000000000..553cfd907f --- /dev/null +++ b/apps/v4/app/r/registries.json/route.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { loadRegistryHealthSnapshot } from "@/lib/registry-health/blob" +import type { + RegistryHealth, + RegistryHealthSnapshot, +} from "@/lib/registry-health/schema" +import directory from "@/registry/directory.json" + +import { dynamic, GET, revalidate } from "./route" + +vi.mock("@/lib/registry-health/blob", () => ({ + loadRegistryHealthSnapshot: vi.fn(), +})) + +const GENERATED_AT = "2026-08-24T12:00:00.000Z" +const loadSnapshot = vi.mocked(loadRegistryHealthSnapshot) + +function createHealth(overrides: Partial = {}) { + return { + schemaVersion: 1, + scoreVersion: 1, + status: "healthy", + statusReason: { + code: "healthy_thresholds", + message: "Recent checks are within healthy thresholds", + }, + score: 90, + breakdown: { + reliability: 40, + correctness: 23, + installability: 18, + hygiene: 9, + }, + availability7d: 0.99, + availability30d: 0.98, + monitoringLimited: false, + firstObservedAt: "2026-08-01T00:00:00.000Z", + checkedAt: GENERATED_AT, + lastSuccessfulCheck: GENERATED_AT, + hidden: false, + ...overrides, + } satisfies RegistryHealth +} + +function createSnapshot(registries: RegistryHealthSnapshot["registries"]) { + return { + schemaVersion: 1, + scoreVersion: 1, + generatedAt: GENERATED_AT, + globalMeans: { + availability7d: 0.85, + availability30d: 0.85, + indexSchema: 0.9, + itemValidity: 0.9, + dryRun: 0.9, + }, + registries, + } satisfies RegistryHealthSnapshot +} + +describe("GET /r/registries.json", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-08-24T13:00:00.000Z")) + vi.stubEnv("REGISTRY_HEALTH_ENABLED", "1") + vi.stubEnv("BLOB_READ_WRITE_TOKEN", "test-token") + loadSnapshot.mockReset() + vi.spyOn(console, "warn").mockImplementation(() => {}) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.restoreAllMocks() + }) + + it("uses static ISR so requests do not read Blob directly", () => { + expect(dynamic).toBe("force-static") + expect(revalidate).toBe(300) + }) + + it("merges fresh health by exact namespace", async () => { + const first = directory[0] + loadSnapshot.mockResolvedValue( + createSnapshot({ [first.name]: createHealth() }) + ) + + const response = await GET() + const payload = await response.json() + + expect(response.status).toBe(200) + expect(payload[0]).toMatchObject({ + name: first.name, + homepage: first.homepage, + url: first.url, + description: first.description, + health: { + score: 90, + statusReason: { + code: "healthy_thresholds", + message: "Recent checks are within healthy thresholds", + }, + }, + }) + expect(loadSnapshot).toHaveBeenCalledWith({ + token: "test-token", + abortSignal: expect.any(AbortSignal), + }) + }) + + it("omits health for a registry missing from the snapshot", async () => { + loadSnapshot.mockResolvedValue(createSnapshot({})) + + const response = await GET() + const payload = await response.json() + + expect(payload[0]).not.toHaveProperty("health") + }) + + it("returns the original payload when health is disabled", async () => { + vi.stubEnv("REGISTRY_HEALTH_ENABLED", "0") + + const response = await GET() + const payload = await response.json() + + expect(loadSnapshot).not.toHaveBeenCalled() + expect(payload[0]).toEqual({ + name: directory[0].name, + homepage: directory[0].homepage, + url: directory[0].url, + description: directory[0].description, + }) + }) + + it("fails open for a stale snapshot", async () => { + vi.setSystemTime(new Date("2026-08-25T00:00:00.000Z")) + loadSnapshot.mockResolvedValue(createSnapshot({})) + + const response = await GET() + const payload = await response.json() + + expect(response.status).toBe(200) + expect(payload[0]).not.toHaveProperty("health") + }) + + it.each(["a malformed snapshot", "an unknown version", "a Blob 500"])( + "fails open for %s", + async (failure) => { + loadSnapshot.mockRejectedValue(new Error(failure)) + + const response = await GET() + const payload = await response.json() + + expect(response.status).toBe(200) + expect(payload[0]).not.toHaveProperty("health") + } + ) + + it("fails open when the snapshot request times out", async () => { + loadSnapshot.mockImplementation( + ({ abortSignal }) => + new Promise((_resolve, reject) => { + abortSignal?.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")) + }) + }) + ) + + const responsePromise = GET() + await vi.advanceTimersByTimeAsync(2500) + const response = await responsePromise + const payload = await response.json() + + expect(response.status).toBe(200) + expect(payload[0]).not.toHaveProperty("health") + }) + + it("fails open when the snapshot is missing", async () => { + loadSnapshot.mockResolvedValue(null) + + const response = await GET() + const payload = await response.json() + + expect(payload[0]).not.toHaveProperty("health") + }) +}) diff --git a/apps/v4/app/r/registries.json/route.ts b/apps/v4/app/r/registries.json/route.ts index db5ba1f039..61f6760568 100644 --- a/apps/v4/app/r/registries.json/route.ts +++ b/apps/v4/app/r/registries.json/route.ts @@ -1,16 +1,63 @@ import { NextResponse } from "next/server" +import { + createPublicRegistryDirectory, + registryDirectorySchema, +} from "@/lib/registry-directory" +import { loadRegistryHealthSnapshot } from "@/lib/registry-health/blob" import directory from "@/registry/directory.json" export const dynamic = "force-static" +export const revalidate = 300 + +const SNAPSHOT_STALE_AFTER_MS = 6 * 60 * 60 * 1000 +const SNAPSHOT_TIMEOUT_MS = 2500 +const registries = createPublicRegistryDirectory( + registryDirectorySchema.parse(directory) +) + +async function getHealthSnapshot() { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), SNAPSHOT_TIMEOUT_MS) + + try { + const snapshot = await loadRegistryHealthSnapshot({ + token: process.env.BLOB_READ_WRITE_TOKEN, + abortSignal: controller.signal, + }) + if (!snapshot) throw new Error("missing_snapshot") + + const age = Date.now() - new Date(snapshot.generatedAt).getTime() + if (age < 0 || age > SNAPSHOT_STALE_AFTER_MS) { + throw new Error("stale_snapshot") + } + + return snapshot + } catch (error) { + console.warn( + "Registry health snapshot unavailable:", + error instanceof Error ? error.message : "unknown_error" + ) + return null + } finally { + clearTimeout(timeout) + } +} export async function GET() { - const registries = directory.map(({ name, homepage, url, description }) => ({ - name, - homepage, - url, - description, - })) + if (process.env.REGISTRY_HEALTH_ENABLED !== "1") { + return NextResponse.json(registries) + } - return NextResponse.json(registries) + const snapshot = await getHealthSnapshot() + if (!snapshot) { + return NextResponse.json(registries) + } + + return NextResponse.json( + registries.map((registry) => { + const health = snapshot.registries[registry.name] + return health ? { ...registry, health } : registry + }) + ) } diff --git a/apps/v4/content/docs/registry/health.mdx b/apps/v4/content/docs/registry/health.mdx new file mode 100644 index 0000000000..137283d3d1 --- /dev/null +++ b/apps/v4/content/docs/registry/health.mdx @@ -0,0 +1,175 @@ +--- +title: Registry Health +description: How public registries are monitored and scored. +--- + +Registries can change or go offline after they are added to the directory. +Registry Health keeps checking them so users can see whether they are working +and maintainers can catch problems early. + +We check that a registry is online, follows the registry format, and works with +the `shadcn` CLI. These checks are combined into a status and a score. + + + Scores are collected but do not yet affect registry visibility or ordering. + The scoring model and its thresholds may change before launch. + + +**Registry Health only applies to registries listed in the shadcn/ui Registry +Directory.** It does not monitor or affect private registries, registries +configured directly in your project, or GitHub registries used through +`owner/repo/item` addresses. + +Monitoring starts after a registry is published. It does not decide whether a +registry can be added, and failed checks do not unpublish it. A future version +of the Registry Directory may hide an unavailable registry from normal +browsing without removing it from the API. + +## What we check + +| Cadence | Check | What we look for | +| ------- | -------------- | ----------------------------------------------------------- | +| Hourly | Registry index | The index is online, valid, and correctly configured. | +| Daily | Registry items | A rotating sample of items can be downloaded and validated. | +| Weekly | CLI | A rotating item works with `shadcn add --dry-run`. | + +Item checks rotate through the catalog over approximately 30 days. Scheduled +runs are best-effort, so we use the time of each real observation instead of +assuming that every scheduled check ran. + +## Status + +The status tells you how a registry is doing right now: + +| Status | What it means | +| --------------- | ------------------------------------------------------------------------- | +| **Observing** | We are collecting the first 24 index checks over at least 24 hours. | +| **Healthy** | The initial observation period is complete and recent checks are passing. | +| **Degraded** | The registry is online, but one or more recent checks are failing. | +| **Unavailable** | The registry index has not passed a check for at least 24 hours. | + +A registry can become **Unavailable** before the initial observation period is +complete. We mark a registry as **Degraded** when: + +- The index fails three checks in a row. +- The latest index does not match the registry schema. +- Fewer than 90% of sampled items pass after at least 10 checks. +- The two most recent CLI checks fail. + +The status can react to a recent problem before the overall score changes much. +This means a registry can have a high score and still be **Degraded**. + +Each status includes a short, human-readable reason. API consumers can use the +stable `statusReason.code` or display `statusReason.message`. Raw errors from +the monitor are not published. + +After an availability failure, a registry needs two successful index and schema +checks in a row to recover. Other degradations clear when their failing checks +return to healthy levels. + +## Score + +Every registry receives one score out of **100 points**. It is the sum of four +components. The component values are not separate scores out of 100. + +| Component | Points | What it measures | +| -------------- | -----: | ----------------------------------------------------------------------- | +| Reliability | 45 | How often the registry index was available over the last 7 and 30 days. | +| Correctness | 25 | Whether the index and sampled items match the registry schema. | +| Installability | 20 | Whether sampled items work with the `shadcn` CLI. | +| Registry setup | 10 | HTTPS, JSON responses, unique item names, and a matching registry name. | + +The score is about reliability and compatibility. It is not a measure of +popularity, code quality, design quality, or how many items a registry contains. + +### How the points are calculated + +Recent availability matters more than older availability: + +```text +Reliability = 45 * (0.65 * availability7d + 0.35 * availability30d) +``` + +Correctness gives up to 10 points for the index and 15 points for sampled items: + +```text +Correctness = 10 * indexSchemaPassRate30d + + 15 * sampledItemPassRate30d +``` + +Installability is based on CLI checks: + +```text +Installability = 20 * dryRunPassRate30d +``` + +Registry setup has four checks worth 2.5 points each. If we have not observed a +setup signal yet, it receives 1.25 points until it can be checked. + +Each component is rounded to three decimal places. The published score is the +sum of those rounded values. + +## Scores for new registries + +A new registry does not have enough history for a reliable score. We blend its +early results with the average across monitored registries. As more checks are +collected, the registry's own results have more influence. + +This prevents one successful check from producing a perfect score and one +failed check from producing a zero. It also explains why an **Observing** +registry can already have a score close to the overall average. + +For API consumers, the smoothing formula is: + +```text +(successes + globalMean * priorWeight) / (observations + priorWeight) +``` + +The prior weight follows the cadence of each check: + +| Signal | Prior weight | +| --------------------- | -----------: | +| Availability | 24 | +| Index schema validity | 12 | +| Sampled item validity | 10 | +| CLI checks | 3 | + +When there is not enough registry-wide data to calculate an average, we start +with an 85% availability prior and a 90% prior for the other measured rates. A +change to the formula, weights, or thresholds requires a new `scoreVersion`. + +## Health data in the API + +`/r/registries.json` adds an optional `health` object to each registry. It +includes: + +- The current `status` and `statusReason`. +- The overall `score` and its component `breakdown`. +- Smoothed `availability7d` and `availability30d` rates from 0 to 1. +- `firstObservedAt`, `checkedAt`, and `lastSuccessfulCheck` timestamps. +- `schemaVersion` and `scoreVersion` for integrations. + +The object also includes two flags: + +- `monitoringLimited` means the latest registry index request was blocked by a + CDN or WAF challenge. Challenge responses do not count as availability or + sampled item validation failures. +- `hidden` becomes `true` after seven continuous days of unavailability. + +The Registry Directory does not currently use the `hidden` flag. + +## Monitoring limitations + +All checks come from one hosted runner. Latency is therefore kept as a private +diagnostic and does not affect the score because some regions would have an +unfair advantage. + +CDN and WAF challenges are also handled separately. A challenge tells us that +our runner could not complete the check, not that the registry is unavailable +to everyone. + +## What happens next + +We will collect and review baseline data before using Registry Health in the +Directory. Ranking, filtering, and health UI will ship separately once the +monitoring data is reliable. diff --git a/apps/v4/content/docs/registry/meta.json b/apps/v4/content/docs/registry/meta.json index 965ff96424..5d4554d9a3 100644 --- a/apps/v4/content/docs/registry/meta.json +++ b/apps/v4/content/docs/registry/meta.json @@ -5,6 +5,7 @@ "getting-started", "github", "registry-index", + "health", "examples", "namespace", "authentication", diff --git a/apps/v4/content/docs/registry/registry-index.mdx b/apps/v4/content/docs/registry/registry-index.mdx index 180ddc2077..d18428d678 100644 --- a/apps/v4/content/docs/registry/registry-index.mdx +++ b/apps/v4/content/docs/registry/registry-index.mdx @@ -21,6 +21,10 @@ namespaces such as `@acme`. Once you have submitted your request, it will be validated and reviewed by the team. +Once the pull request is merged, your registry is published immediately. +[Registry Health](/docs/registry/health) starts monitoring it after publication +and does not delay or gate publication while it collects baseline data. + ## Requirements 1. The registry must be open source and publicly accessible. diff --git a/apps/v4/lib/docs.ts b/apps/v4/lib/docs.ts index b92ddf2184..83daabbc63 100644 --- a/apps/v4/lib/docs.ts +++ b/apps/v4/lib/docs.ts @@ -6,6 +6,7 @@ export const PAGES_NEW = [ "/docs/components/base/questionnaire", "/docs/components/aria/questionnaire", "/docs/react/questionnaire", + "/docs/registry/health", ] export const PAGES_UPDATED = [] diff --git a/apps/v4/lib/registry-directory.ts b/apps/v4/lib/registry-directory.ts new file mode 100644 index 0000000000..4543abb797 --- /dev/null +++ b/apps/v4/lib/registry-directory.ts @@ -0,0 +1,76 @@ +import { z } from "zod" + +const registryNamespaceSchema = z.string().regex(/^@[a-zA-Z0-9][a-zA-Z0-9_-]*$/) + +const registryDirectoryEntrySchema = z + .object({ + name: registryNamespaceSchema, + homepage: z.string().url(), + url: z + .string() + .url() + .refine((url) => url.includes("{name}"), { + message: "URL must include {name} placeholder", + }), + description: z.string(), + author: z.string().optional(), + logo: z.string(), + }) + .strict() + +const registryDirectorySchema = z + .array(registryDirectoryEntrySchema) + .superRefine((entries, context) => { + const names = new Set() + + entries.forEach((entry, index) => { + const name = entry.name.toLowerCase() + if (names.has(name)) { + context.addIssue({ + code: "custom", + message: `Duplicate registry namespace: ${entry.name}`, + path: [index, "name"], + }) + } + names.add(name) + }) + }) + +const publicRegistryDirectoryEntrySchema = registryDirectoryEntrySchema.pick({ + name: true, + homepage: true, + url: true, + description: true, +}) + +const publicRegistryDirectorySchema = z.array( + publicRegistryDirectoryEntrySchema +) + +type RegistryDirectoryEntry = z.infer + +function createPublicRegistryDirectory( + entries: readonly RegistryDirectoryEntry[] +) { + return entries.map(({ name, homepage, url, description }) => ({ + name, + homepage, + url, + description, + })) +} + +function normalizeRegistryName(value: string) { + return value.toLowerCase().replaceAll(" ", "").replace(/^@/, "") +} + +export { + createPublicRegistryDirectory, + normalizeRegistryName, + publicRegistryDirectoryEntrySchema, + publicRegistryDirectorySchema, + registryDirectoryEntrySchema, + registryDirectorySchema, + registryNamespaceSchema, +} +export type { RegistryDirectoryEntry } diff --git a/apps/v4/lib/registry-health/blob.test.ts b/apps/v4/lib/registry-health/blob.test.ts new file mode 100644 index 0000000000..3bdb4ca98c --- /dev/null +++ b/apps/v4/lib/registry-health/blob.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from "vitest" + +import { + cleanRegistryHealthHistory, + loadRegistryHealthSnapshot, + loadRegistryMonitorState, + publishRegistryHealth, + type BlobOperations, +} from "./blob" +import type { + RegistryHealthSnapshot, + RegistryMonitorRun, + RegistryMonitorState, +} from "./schema" + +function streamJson(value: unknown) { + const bytes = new TextEncoder().encode(JSON.stringify(value)) + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes) + controller.close() + }, + }) +} + +function createOperations(overrides: Partial = {}) { + return { + get: vi.fn(async () => null), + put: vi.fn(async (pathname) => ({ + url: `https://blob.example.com/${pathname}`, + })), + list: vi.fn(async () => ({ blobs: [], hasMore: false })), + del: vi.fn(async () => {}), + ...overrides, + } satisfies BlobOperations +} + +const STATE: RegistryMonitorState = { + schemaVersion: 1, + scoreVersion: 1, + updatedAt: "2026-08-24T12:00:00.000Z", + registries: {}, +} + +const SNAPSHOT: RegistryHealthSnapshot = { + schemaVersion: 1, + scoreVersion: 1, + generatedAt: "2026-08-24T12:00:00.000Z", + globalMeans: { + availability7d: 0.85, + availability30d: 0.85, + indexSchema: 0.9, + itemValidity: 0.9, + dryRun: 0.9, + }, + registries: {}, +} + +const RUN: RegistryMonitorRun = { + schemaVersion: 1, + startedAt: "2026-08-24T12:00:00.000Z", + completedAt: "2026-08-24T12:01:00.000Z", + mode: "hourly", + totals: { + registries: 0, + reachable: 0, + unavailable: 0, + challenges: 0, + itemChecks: 0, + dryRuns: 0, + }, + results: {}, + diagnostics: [], +} + +describe("loadRegistryMonitorState", () => { + it("returns null on the first run", async () => { + const state = await loadRegistryMonitorState({ + token: "test-token", + operations: createOperations(), + }) + + expect(state).toBeNull() + }) + + it("validates stored state before using it", async () => { + const operations = createOperations({ + get: vi.fn(async () => ({ stream: streamJson(STATE) })), + }) + + await expect( + loadRegistryMonitorState({ token: "test-token", operations }) + ).resolves.toEqual(STATE) + expect(operations.get).toHaveBeenCalledWith( + "registry-health/v1/state.json", + { + access: "private", + token: "test-token", + useCache: false, + } + ) + }) +}) + +describe("loadRegistryHealthSnapshot", () => { + it("reads and validates the private latest snapshot", async () => { + const abortController = new AbortController() + const operations = createOperations({ + get: vi.fn(async () => ({ stream: streamJson(SNAPSHOT) })), + }) + + await expect( + loadRegistryHealthSnapshot({ + token: "test-token", + abortSignal: abortController.signal, + operations, + }) + ).resolves.toEqual(SNAPSHOT) + expect(operations.get).toHaveBeenCalledWith( + "registry-health/v1/latest.json", + { + access: "private", + token: "test-token", + useCache: false, + abortSignal: abortController.signal, + } + ) + }) + + it("rejects an invalid latest snapshot", async () => { + const operations = createOperations({ + get: vi.fn(async () => ({ + stream: streamJson({ ...SNAPSHOT, schemaVersion: 2 }), + })), + }) + + await expect( + loadRegistryHealthSnapshot({ token: "test-token", operations }) + ).rejects.toThrow() + }) +}) + +describe("publishRegistryHealth", () => { + it("publishes immutable history before overwriting state and latest", async () => { + const operations = createOperations() + const result = await publishRegistryHealth({ + state: STATE, + snapshot: SNAPSHOT, + run: RUN, + token: "test-token", + operations, + }) + const calls = vi.mocked(operations.put).mock.calls + + expect(calls.map(([pathname]) => pathname)).toEqual([ + "registry-health/v1/runs/2026-08-24T12-00-00-000Z.json", + "registry-health/v1/daily/2026-08-24.json", + "registry-health/v1/state.json", + "registry-health/v1/latest.json", + ]) + expect(calls[0][2].allowOverwrite).toBe(false) + expect(calls[2][2].allowOverwrite).toBe(true) + expect(calls[3][2].allowOverwrite).toBe(true) + expect(calls.every(([, , options]) => options.access === "private")).toBe( + true + ) + expect(result.latestPath).toBe("registry-health/v1/latest.json") + }) +}) + +describe("cleanRegistryHealthHistory", () => { + it("paginates listings and deletes expired history", async () => { + const list = vi + .fn() + .mockResolvedValueOnce({ + blobs: [ + { + pathname: "registry-health/v1/runs/old.json", + uploadedAt: new Date("2026-08-01T00:00:00.000Z"), + }, + ], + cursor: "next", + hasMore: true, + }) + .mockResolvedValueOnce({ blobs: [], hasMore: false }) + .mockResolvedValueOnce({ blobs: [], hasMore: false }) + const operations = createOperations({ list }) + + const result = await cleanRegistryHealthHistory({ + token: "test-token", + now: new Date("2026-08-24T12:00:00.000Z"), + operations, + }) + + expect(list).toHaveBeenCalledTimes(3) + expect(operations.del).toHaveBeenCalledWith( + ["registry-health/v1/runs/old.json"], + { token: "test-token" } + ) + expect(result.deleted).toBe(1) + }) +}) diff --git a/apps/v4/lib/registry-health/blob.ts b/apps/v4/lib/registry-health/blob.ts new file mode 100644 index 0000000000..8f72b9e3cd --- /dev/null +++ b/apps/v4/lib/registry-health/blob.ts @@ -0,0 +1,290 @@ +import { + del as deleteBlob, + get as getBlob, + list as listBlobs, + put as putBlob, +} from "@vercel/blob" + +import { + registryHealthSnapshotSchema, + registryMonitorStateSchema, + type RegistryHealthSnapshot, + type RegistryMonitorRun, + type RegistryMonitorState, +} from "./schema" + +const BLOB_PREFIX = "registry-health/v1" +const STATE_PATH = `${BLOB_PREFIX}/state.json` +const LATEST_PATH = `${BLOB_PREFIX}/latest.json` + +type BlobListItem = { + pathname: string + uploadedAt: Date +} + +type BlobOperations = { + get: ( + pathname: string, + options: { + access: "private" + token?: string + useCache: false + abortSignal?: AbortSignal + } + ) => Promise<{ stream: ReadableStream | null } | null> + put: ( + pathname: string, + body: string, + options: { + access: "private" + token?: string + addRandomSuffix: false + allowOverwrite?: boolean + cacheControlMaxAge: number + contentType: "application/json" + } + ) => Promise<{ url: string }> + list: (options: { + prefix: string + cursor?: string + limit: number + token?: string + }) => Promise<{ + blobs: BlobListItem[] + cursor?: string + hasMore: boolean + }> + del: (pathnames: string[], options: { token?: string }) => Promise +} + +const defaultOperations: BlobOperations = { + get: getBlob, + put: putBlob, + list: listBlobs, + del: deleteBlob, +} + +async function readJsonStream(stream: ReadableStream) { + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + size += value.byteLength + } + + const body = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + + return JSON.parse(new TextDecoder().decode(body)) as unknown +} + +export async function loadRegistryMonitorState({ + token, + operations = defaultOperations, +}: { + token?: string + operations?: BlobOperations +}) { + const result = await operations.get(STATE_PATH, { + access: "private", + token, + useCache: false, + }) + + if (!result?.stream) return null + + return registryMonitorStateSchema.parse(await readJsonStream(result.stream)) +} + +export async function loadRegistryHealthSnapshot({ + token, + abortSignal, + operations = defaultOperations, +}: { + token?: string + abortSignal?: AbortSignal + operations?: BlobOperations +}) { + const result = await operations.get(LATEST_PATH, { + access: "private", + token, + useCache: false, + abortSignal, + }) + + if (!result?.stream) return null + + return registryHealthSnapshotSchema.parse(await readJsonStream(result.stream)) +} + +function getRunPath(run: RegistryMonitorRun) { + const timestamp = run.startedAt.replaceAll(":", "-").replaceAll(".", "-") + return `${BLOB_PREFIX}/runs/${timestamp}.json` +} + +function getDailyPath(snapshot: RegistryHealthSnapshot) { + return `${BLOB_PREFIX}/daily/${snapshot.generatedAt.slice(0, 10)}.json` +} + +function getDailyDocument( + state: RegistryMonitorState, + snapshot: RegistryHealthSnapshot +) { + const date = snapshot.generatedAt.slice(0, 10) + return { + schemaVersion: snapshot.schemaVersion, + scoreVersion: snapshot.scoreVersion, + date, + generatedAt: snapshot.generatedAt, + globalMeans: snapshot.globalMeans, + registries: Object.fromEntries( + Object.entries(state.registries).map(([name, entry]) => [ + name, + { + aggregate: entry.daily.find((bucket) => bucket.date === date) ?? null, + health: snapshot.registries[name], + }, + ]) + ), + } +} + +async function putJson({ + pathname, + value, + token, + allowOverwrite = false, + operations, +}: { + pathname: string + value: unknown + token?: string + allowOverwrite?: boolean + operations: BlobOperations +}) { + return operations.put(pathname, JSON.stringify(value), { + access: "private", + token, + addRandomSuffix: false, + allowOverwrite, + cacheControlMaxAge: 60, + contentType: "application/json", + }) +} + +export async function publishRegistryHealth({ + state, + snapshot, + run, + token, + operations = defaultOperations, +}: { + state: RegistryMonitorState + snapshot: RegistryHealthSnapshot + run: RegistryMonitorRun + token?: string + operations?: BlobOperations +}) { + await putJson({ + pathname: getRunPath(run), + value: run, + token, + operations, + }) + await putJson({ + pathname: getDailyPath(snapshot), + value: getDailyDocument(state, snapshot), + token, + allowOverwrite: true, + operations, + }) + await putJson({ + pathname: STATE_PATH, + value: state, + token, + allowOverwrite: true, + operations, + }) + await putJson({ + pathname: LATEST_PATH, + value: snapshot, + token, + allowOverwrite: true, + operations, + }) + + return { latestPath: LATEST_PATH } +} + +async function listAll({ + prefix, + token, + operations, +}: { + prefix: string + token?: string + operations: BlobOperations +}) { + const blobs: BlobListItem[] = [] + let cursor: string | undefined + + do { + const page = await operations.list({ + prefix, + cursor, + limit: 1000, + token, + }) + blobs.push(...page.blobs) + cursor = page.hasMore ? page.cursor : undefined + } while (cursor) + + return blobs +} + +export async function cleanRegistryHealthHistory({ + token, + now = new Date(), + operations = defaultOperations, +}: { + token?: string + now?: Date + operations?: BlobOperations +}) { + const policies = [ + { prefix: `${BLOB_PREFIX}/runs/`, retentionDays: 14 }, + { prefix: `${BLOB_PREFIX}/daily/`, retentionDays: 90 }, + ] + const stale: string[] = [] + + for (const policy of policies) { + const blobs = await listAll({ + prefix: policy.prefix, + token, + operations, + }) + const cutoff = now.getTime() - policy.retentionDays * 24 * 60 * 60 * 1000 + stale.push( + ...blobs + .filter((blob) => blob.uploadedAt.getTime() < cutoff) + .map((blob) => blob.pathname) + ) + } + + for (let index = 0; index < stale.length; index += 100) { + await operations.del(stale.slice(index, index + 100), { token }) + } + + return { deleted: stale.length } +} + +export { BLOB_PREFIX, LATEST_PATH, STATE_PATH } +export type { BlobOperations } diff --git a/apps/v4/lib/registry-health/dry-run.test.ts b/apps/v4/lib/registry-health/dry-run.test.ts new file mode 100644 index 0000000000..2aa0bb069d --- /dev/null +++ b/apps/v4/lib/registry-health/dry-run.test.ts @@ -0,0 +1,53 @@ +import { promises as fs } from "node:fs" +import os from "node:os" +import path from "node:path" +import { describe, expect, it } from "vitest" + +import { getDryRunEnvironment, runLocalCliDryRun } from "./dry-run" + +describe("getDryRunEnvironment", () => { + it("allowlists process variables and excludes credentials", () => { + const environment = getDryRunEnvironment("/tmp/registry-health-test") + + expect(environment).toMatchObject({ + CI: "1", + NO_COLOR: "1", + XDG_CACHE_HOME: "/tmp/registry-health-test", + XDG_CONFIG_HOME: "/tmp/registry-health-test", + }) + expect(environment).not.toHaveProperty("BLOB_READ_WRITE_TOKEN") + expect(environment).not.toHaveProperty("GITHUB_TOKEN") + expect(environment).not.toHaveProperty("GH_TOKEN") + }) +}) + +describe("runLocalCliDryRun", () => { + it("force kills a CLI process that ignores SIGTERM", async () => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), "registry-health-cli-test-") + ) + const cliPath = path.join(directory, "cli.mjs") + await fs.writeFile( + cliPath, + 'process.on("SIGTERM", () => {}); setInterval(() => {}, 1000)' + ) + + try { + const result = await runLocalCliDryRun({ + namespace: "@acme", + item: "button", + registryUrl: "https://acme.example.com/r/{name}.json", + cliPath, + timeoutMs: 25, + killGraceMs: 25, + }) + + expect(result).toMatchObject({ + success: false, + failureCode: "timeout", + }) + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/v4/lib/registry-health/dry-run.ts b/apps/v4/lib/registry-health/dry-run.ts new file mode 100644 index 0000000000..f07b0ac6d2 --- /dev/null +++ b/apps/v4/lib/registry-health/dry-run.ts @@ -0,0 +1,146 @@ +import { spawn, type ChildProcess } from "node:child_process" +import { promises as fs } from "node:fs" +import os from "node:os" +import path from "node:path" + +import type { RegistryDryRunResult } from "./monitor" + +async function runLocalCliDryRun({ + namespace, + item, + registryUrl, + cliPath, + timeoutMs = 60_000, + killGraceMs = 5_000, +}: { + namespace: string + item: string + registryUrl: string + cliPath: string + timeoutMs?: number + killGraceMs?: number +}): Promise { + const startedAt = Date.now() + const temporaryDirectory = await fs.mkdtemp( + path.join(os.tmpdir(), "shadcn-registry-health-") + ) + + try { + await fs.mkdir(path.join(temporaryDirectory, "app"), { recursive: true }) + await Promise.all([ + fs.writeFile( + path.join(temporaryDirectory, "components.json"), + JSON.stringify( + { + $schema: "https://ui.shadcn.com/schema.json", + style: "new-york", + rsc: true, + tsx: true, + tailwind: { + config: "", + css: "app/globals.css", + baseColor: "neutral", + cssVariables: true, + prefix: "", + }, + iconLibrary: "lucide", + aliases: { + components: "@/components", + utils: "@/lib/utils", + ui: "@/components/ui", + lib: "@/lib", + hooks: "@/hooks", + }, + registries: { + [namespace]: registryUrl, + }, + }, + null, + 2 + ) + ), + fs.writeFile(path.join(temporaryDirectory, "app/globals.css"), ""), + fs.writeFile( + path.join(temporaryDirectory, "package.json"), + JSON.stringify({ private: true }) + ), + ]) + + return await new Promise((resolve) => { + const child: ChildProcess = spawn( + process.execPath, + [ + cliPath, + "add", + `${namespace}/${item}`, + "--dry-run", + "--yes", + "--cwd", + temporaryDirectory, + ], + { + cwd: temporaryDirectory, + env: getDryRunEnvironment(temporaryDirectory), + stdio: "ignore", + } + ) + let completed = false + let timedOut = false + let forceKillTimeout: NodeJS.Timeout | undefined + const finish = (result: RegistryDryRunResult) => { + if (completed) return + completed = true + clearTimeout(timeout) + if (forceKillTimeout) clearTimeout(forceKillTimeout) + resolve(result) + } + const timeout = setTimeout(() => { + timedOut = true + child.kill("SIGTERM") + forceKillTimeout = setTimeout(() => { + if (!completed) child.kill("SIGKILL") + }, killGraceMs) + }, timeoutMs) + + child.on("error", () => + finish({ + success: false, + failureCode: timedOut ? "timeout" : "spawn_error", + durationMs: Date.now() - startedAt, + }) + ) + child.on("exit", (code, signal) => { + const failureCode = timedOut + ? "timeout" + : code === 0 + ? undefined + : signal + ? "terminated" + : `exit_${code}` + + finish({ + success: !timedOut && code === 0, + failureCode, + durationMs: Date.now() - startedAt, + }) + }) + }) + } finally { + await fs.rm(temporaryDirectory, { recursive: true, force: true }) + } +} + +function getDryRunEnvironment(temporaryDirectory: string) { + return { + PATH: process.env.PATH, + TMPDIR: os.tmpdir(), + XDG_CACHE_HOME: temporaryDirectory, + XDG_CONFIG_HOME: temporaryDirectory, + npm_config_userconfig: path.join(temporaryDirectory, ".npmrc"), + NODE_ENV: "production", + CI: "1", + NO_COLOR: "1", + } satisfies NodeJS.ProcessEnv +} + +export { getDryRunEnvironment, runLocalCliDryRun } diff --git a/apps/v4/lib/registry-health/monitor.test.ts b/apps/v4/lib/registry-health/monitor.test.ts new file mode 100644 index 0000000000..59a22d7fb4 --- /dev/null +++ b/apps/v4/lib/registry-health/monitor.test.ts @@ -0,0 +1,613 @@ +import { describe, expect, it, vi } from "vitest" + +import type { RegistryDirectoryEntry } from "../registry-directory" +import { runRegistryMonitor } from "./monitor" +import type { RegistryMonitorEntryState, RegistryMonitorState } from "./schema" + +const NOW = new Date("2026-08-24T12:00:00.000Z") +const DIRECTORY: RegistryDirectoryEntry[] = [ + { + name: "@acme", + homepage: "https://acme.example.com", + url: "https://acme.example.com/r/{name}.json", + description: "Acme components", + logo: "https://acme.example.com/logo.svg", + }, +] +function createEntryState(overrides: Partial = {}) { + return { + firstObservedAt: "2026-08-01T00:00:00.000Z", + lastSuccessfulCheck: "2026-08-24T10:00:00.000Z", + status: "healthy", + consecutiveSuccessfulIndexes: 0, + consecutiveIndexFailures: 0, + availabilityRecoveryRequired: false, + itemCursor: 0, + itemNames: [], + recentIndex: [], + recentDryRuns: [], + daily: [], + latestHygiene: { + contentTypeJson: null, + noDuplicateNames: null, + nameMatches: null, + }, + ...overrides, + } satisfies RegistryMonitorEntryState +} + +function createState(entry = createEntryState()) { + return { + schemaVersion: 1, + scoreVersion: 1, + updatedAt: "2026-08-24T10:00:00.000Z", + registries: { "@acme": entry }, + } satisfies RegistryMonitorState +} + +function createSuccessfulFetch(itemCount = 1) { + return { + ok: true as const, + json: { + name: "acme", + homepage: "https://acme.example.com", + items: Array.from({ length: itemCount }, (_, index) => ({ + name: `item-${index}`, + type: "registry:ui", + files: [], + })), + }, + status: 200, + durationMs: 120, + responseSize: 500, + contentType: "application/json; charset=utf-8", + redirectCount: 0, + finalUrl: "https://acme.example.com/r/registry.json", + } +} + +describe("runRegistryMonitor", () => { + it("records completion separately from the observation time", async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-08-24T12:05:00.000Z")) + + try { + const result = await runRegistryMonitor({ + directory: DIRECTORY, + mode: "hourly", + now: NOW, + fetchJson: vi.fn(async () => createSuccessfulFetch()), + runDryRun: vi.fn(), + }) + + expect(result.run.startedAt).toBe(NOW.toISOString()) + expect(result.run.completedAt).toBe("2026-08-24T12:05:00.000Z") + } finally { + vi.useRealTimers() + } + }) + + it("records a successful hourly index observation", async () => { + const result = await runRegistryMonitor({ + directory: DIRECTORY, + mode: "hourly", + now: NOW, + fetchJson: vi.fn(async () => createSuccessfulFetch(2)), + runDryRun: vi.fn(), + }) + + expect(result.run.totals).toMatchObject({ + registries: 1, + reachable: 1, + unavailable: 0, + challenges: 0, + }) + expect(result.state.registries["@acme"].itemNames).toEqual([ + "item-0", + "item-1", + ]) + expect(result.snapshot.registries["@acme"]).toMatchObject({ + status: "observing", + monitoringLimited: false, + checkedAt: NOW.toISOString(), + }) + }) + + it("starts an outage from the last successful index check", async () => { + const lastSuccessfulCheck = "2026-08-23T11:00:00.000Z" + const result = await runRegistryMonitor({ + directory: DIRECTORY, + previousState: createState( + createEntryState({ + firstObservedAt: "2026-08-01T00:00:00.000Z", + lastSuccessfulCheck, + consecutiveSuccessfulIndexes: 24, + }) + ), + mode: "hourly", + now: NOW, + fetchJson: vi.fn(async () => ({ + ok: false as const, + failureCode: "timeout", + reachable: false, + botChallenge: false, + durationMs: 100, + redirectCount: 0, + finalUrl: "https://acme.example.com/r/registry.json", + })), + runDryRun: vi.fn(), + }) + + expect(result.state.registries["@acme"]).toMatchObject({ + consecutiveSuccessfulIndexes: 0, + consecutiveIndexFailures: 1, + availabilityOutageSince: lastSuccessfulCheck, + availabilityRecoveryRequired: true, + }) + expect(result.snapshot.registries["@acme"].status).toBe("unavailable") + }) + + it("clears availability recovery after two valid index checks", async () => { + const outageSince = "2026-08-16T00:00:00.000Z" + const recentIndex = Array.from({ length: 24 }, (_, index) => ({ + checkedAt: new Date( + NOW.getTime() - (24 - index) * 60 * 60 * 1000 + ).toISOString(), + outcome: "unreachable" as const, + durationMs: 100, + redirectCount: 0, + })) + const previousState = createState( + createEntryState({ + firstObservedAt: "2026-08-01T00:00:00.000Z", + lastSuccessfulCheck: "2026-08-15T23:00:00.000Z", + status: "unavailable", + consecutiveSuccessfulIndexes: 0, + consecutiveIndexFailures: 24, + availabilityOutageSince: outageSince, + availabilityRecoveryRequired: true, + recentIndex, + }) + ) + + const first = await runRegistryMonitor({ + directory: DIRECTORY, + previousState, + mode: "hourly", + now: NOW, + fetchJson: vi.fn(async () => createSuccessfulFetch()), + runDryRun: vi.fn(), + }) + const recovering = first.state.registries["@acme"] + + expect(recovering).toMatchObject({ + consecutiveSuccessfulIndexes: 1, + consecutiveIndexFailures: 0, + availabilityOutageSince: outageSince, + availabilityRecoveryRequired: true, + }) + expect(first.snapshot.registries["@acme"]).toMatchObject({ + status: "unavailable", + hidden: true, + }) + + const second = await runRegistryMonitor({ + directory: DIRECTORY, + previousState: first.state, + mode: "hourly", + now: new Date("2026-08-24T13:00:00.000Z"), + fetchJson: vi.fn(async () => createSuccessfulFetch()), + runDryRun: vi.fn(), + }) + const recovered = second.state.registries["@acme"] + + expect(recovered).toMatchObject({ + consecutiveSuccessfulIndexes: 2, + consecutiveIndexFailures: 0, + availabilityRecoveryRequired: false, + }) + expect(recovered).not.toHaveProperty("availabilityOutageSince") + expect(second.snapshot.registries["@acme"].status).toBe("healthy") + }) + + it("records a challenge without lowering availability", async () => { + const result = await runRegistryMonitor({ + directory: DIRECTORY, + previousState: createState(), + mode: "hourly", + now: NOW, + fetchJson: vi.fn(async () => ({ + ok: false as const, + failureCode: "bot_challenge", + reachable: false, + botChallenge: true, + status: 403, + durationMs: 80, + responseSize: 200, + contentType: "text/html", + redirectCount: 0, + finalUrl: "https://acme.example.com/r/registry.json", + })), + runDryRun: vi.fn(), + }) + + const bucket = result.state.registries["@acme"].daily[0] + const state = result.state.registries["@acme"] + expect(bucket.challengeObservations).toBe(1) + expect(bucket.availabilityObservations).toBe(0) + expect(state).toMatchObject({ + consecutiveSuccessfulIndexes: 0, + consecutiveIndexFailures: 0, + availabilityRecoveryRequired: false, + }) + expect(state).not.toHaveProperty("availabilityOutageSince") + expect(result.snapshot.registries["@acme"].monitoringLimited).toBe(true) + }) + + it("allows an existing outage to become unavailable during a challenge", async () => { + const outageSince = "2026-08-23T11:00:00.000Z" + const previousState = createState( + createEntryState({ + firstObservedAt: "2026-08-01T00:00:00.000Z", + lastSuccessfulCheck: outageSince, + consecutiveIndexFailures: 1, + availabilityOutageSince: outageSince, + recentIndex: [ + { + checkedAt: outageSince, + outcome: "unreachable", + durationMs: 100, + redirectCount: 0, + }, + ], + }) + ) + const result = await runRegistryMonitor({ + directory: DIRECTORY, + previousState, + mode: "hourly", + now: NOW, + fetchJson: vi.fn(async () => ({ + ok: false as const, + failureCode: "bot_challenge", + reachable: false, + botChallenge: true, + status: 403, + durationMs: 80, + responseSize: 200, + contentType: "text/html", + redirectCount: 0, + finalUrl: "https://acme.example.com/r/registry.json", + })), + runDryRun: vi.fn(), + }) + + expect(result.state.registries["@acme"]).toMatchObject({ + consecutiveIndexFailures: 1, + availabilityOutageSince: outageSince, + availabilityRecoveryRequired: false, + }) + expect(result.snapshot.registries["@acme"]).toMatchObject({ + status: "unavailable", + monitoringLimited: true, + }) + }) + + it("refreshes hygiene after a reachable invalid index", async () => { + const result = await runRegistryMonitor({ + directory: DIRECTORY, + previousState: createState( + createEntryState({ + latestHygiene: { + contentTypeJson: true, + noDuplicateNames: true, + nameMatches: true, + }, + }) + ), + mode: "hourly", + now: NOW, + fetchJson: vi.fn(async () => ({ + ...createSuccessfulFetch(), + json: { invalid: true }, + contentType: "text/plain", + })), + runDryRun: vi.fn(), + }) + + expect(result.state.registries["@acme"].latestHygiene).toEqual({ + contentTypeJson: false, + noDuplicateNames: null, + nameMatches: null, + }) + }) + + it("rotates enough daily items to cover a catalog in thirty days", async () => { + const itemNames = Array.from({ length: 61 }, (_, index) => `item-${index}`) + const fetchJson = vi.fn(async (url: string) => { + const item = url.match(/item-\d+/)?.[0] ?? "item-0" + return { + ...createSuccessfulFetch(), + json: { name: item, type: "registry:ui", files: [] }, + finalUrl: url, + } + }) + const result = await runRegistryMonitor({ + directory: DIRECTORY, + previousState: createState(createEntryState({ itemNames })), + mode: "daily", + now: NOW, + fetchJson, + runDryRun: vi.fn(), + }) + + expect(fetchJson).toHaveBeenCalledTimes(3) + expect(result.state.registries["@acme"].itemCursor).toBe(3) + expect(result.run.totals.itemChecks).toBe(3) + expect(result.run.results["@acme"].items).toHaveLength(3) + expect(result.state.lastDailyRunAt).toBe(NOW.toISOString()) + }) + + it("excludes item challenges from validation rates", async () => { + const result = await runRegistryMonitor({ + directory: DIRECTORY, + previousState: createState( + createEntryState({ + itemNames: ["button"], + daily: [ + { + date: "2026-08-24", + availabilitySuccesses: 0, + availabilityObservations: 0, + challengeObservations: 0, + schemaSuccesses: 0, + schemaObservations: 0, + itemSuccesses: 8, + itemObservations: 9, + dryRunSuccesses: 0, + dryRunObservations: 0, + }, + ], + }) + ), + mode: "daily", + now: NOW, + fetchJson: vi.fn(async () => ({ + ok: false as const, + failureCode: "bot_challenge", + reachable: false, + botChallenge: true, + status: 403, + durationMs: 80, + responseSize: 200, + contentType: "text/html", + redirectCount: 0, + finalUrl: "https://acme.example.com/r/button.json", + })), + runDryRun: vi.fn(), + }) + + const bucket = result.state.registries["@acme"].daily[0] + expect(bucket.itemObservations).toBe(9) + expect(bucket.itemSuccesses).toBe(8) + expect(result.run.totals).toMatchObject({ + challenges: 1, + itemChecks: 1, + }) + }) + + it("runs one rotating weekly dry-run item", async () => { + const runDryRun = vi.fn(async () => ({ + success: true, + durationMs: 250, + })) + const result = await runRegistryMonitor({ + directory: DIRECTORY, + previousState: createState( + createEntryState({ itemNames: ["button", "dialog"] }) + ), + mode: "weekly", + now: NOW, + fetchJson: vi.fn(), + runDryRun, + }) + + expect(runDryRun).toHaveBeenCalledWith({ + namespace: "@acme", + item: "button", + registryUrl: DIRECTORY[0].url, + }) + expect(result.run.totals.dryRuns).toBe(1) + expect(result.state.registries["@acme"].recentDryRuns).toHaveLength(1) + expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString()) + }) + + it("continues weekly rotation after dry-run history is trimmed", async () => { + const runDryRun = vi.fn(async () => ({ + success: true, + durationMs: 250, + })) + const recentDryRuns = Array.from({ length: 12 }, (_, index) => ({ + checkedAt: `2026-08-${String(index + 1).padStart(2, "0")}T12:00:00.000Z`, + item: index === 11 ? "dialog" : "button", + success: true, + durationMs: 250, + })) + + await runRegistryMonitor({ + directory: DIRECTORY, + previousState: createState( + createEntryState({ + itemNames: ["button", "dialog", "tooltip"], + recentDryRuns, + }) + ), + mode: "weekly", + now: NOW, + fetchJson: vi.fn(), + runDryRun, + }) + + expect(runDryRun).toHaveBeenCalledWith({ + namespace: "@acme", + item: "tooltip", + registryUrl: DIRECTORY[0].url, + }) + }) + + it("runs delayed auto checks using their last run timestamps", async () => { + const previousState = { + ...createState(createEntryState({ itemNames: ["button"] })), + lastDailyRunAt: "2026-08-23T15:00:00.000Z", + lastWeeklyRunAt: "2026-08-18T12:00:00.000Z", + } + const fetchJson = vi.fn(async (url: string) => + url.endsWith("/registry.json") + ? createSuccessfulFetch() + : { + ...createSuccessfulFetch(), + json: { name: "item-0", type: "registry:ui", files: [] }, + finalUrl: url, + } + ) + const runDryRun = vi.fn(async () => ({ + success: true, + durationMs: 250, + })) + + const result = await runRegistryMonitor({ + directory: DIRECTORY, + previousState, + mode: "auto", + now: NOW, + fetchJson, + runDryRun, + }) + + expect(result.run.totals.itemChecks).toBe(1) + expect(result.run.totals.dryRuns).toBe(1) + expect(result.state.lastDailyRunAt).toBe(NOW.toISOString()) + expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString()) + }) + + it("skips auto checks until their elapsed intervals", async () => { + const previousState = { + ...createState(createEntryState({ itemNames: ["button"] })), + lastDailyRunAt: "2026-08-23T17:00:00.000Z", + lastWeeklyRunAt: "2026-08-19T12:00:00.000Z", + } + + const result = await runRegistryMonitor({ + directory: DIRECTORY, + previousState, + mode: "auto", + now: NOW, + fetchJson: vi.fn(async () => createSuccessfulFetch()), + runDryRun: vi.fn(), + }) + + expect(result.run.totals.itemChecks).toBe(0) + expect(result.run.totals.dryRuns).toBe(0) + expect(result.state.lastDailyRunAt).toBe(previousState.lastDailyRunAt) + expect(result.state.lastWeeklyRunAt).toBe(previousState.lastWeeklyRunAt) + }) + + it("runs auto checks for state without last run timestamps", async () => { + const previousState = createState( + createEntryState({ itemNames: ["button"] }) + ) + const fetchJson = vi.fn(async (url: string) => + url.endsWith("/registry.json") + ? createSuccessfulFetch() + : { + ...createSuccessfulFetch(), + json: { name: "item-0", type: "registry:ui", files: [] }, + finalUrl: url, + } + ) + + const result = await runRegistryMonitor({ + directory: DIRECTORY, + previousState, + mode: "auto", + now: NOW, + fetchJson, + runDryRun: vi.fn(async () => ({ + success: true, + durationMs: 250, + })), + }) + + expect(result.run.totals.itemChecks).toBe(1) + expect(result.run.totals.dryRuns).toBe(1) + expect(result.state.lastDailyRunAt).toBe(NOW.toISOString()) + expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString()) + }) + + it("runs auto checks when last run timestamps are in the future", async () => { + const previousState = { + ...createState(createEntryState({ itemNames: ["button"] })), + lastDailyRunAt: "2026-08-25T12:00:00.000Z", + lastWeeklyRunAt: "2026-08-25T12:00:00.000Z", + } + + const result = await runRegistryMonitor({ + directory: DIRECTORY, + previousState, + mode: "auto", + now: NOW, + fetchJson: vi.fn(async () => createSuccessfulFetch()), + runDryRun: vi.fn(async () => ({ + success: true, + durationMs: 250, + })), + }) + + expect(result.run.totals.itemChecks).toBe(1) + expect(result.run.totals.dryRuns).toBe(1) + expect(result.state.lastDailyRunAt).toBe(NOW.toISOString()) + expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString()) + }) + + it("limits concurrent CLI dry runs to four", async () => { + const directory = Array.from({ length: 6 }, (_, index) => ({ + name: `@registry${index}`, + homepage: `https://registry${index}.example.com`, + url: `https://registry${index}.example.com/r/{name}.json`, + description: `Registry ${index}`, + logo: `https://registry${index}.example.com/logo.svg`, + })) + const previousState: RegistryMonitorState = { + schemaVersion: 1, + scoreVersion: 1, + updatedAt: "2026-08-24T10:00:00.000Z", + registries: Object.fromEntries( + directory.map((entry) => [ + entry.name, + createEntryState({ itemNames: ["button"] }), + ]) + ), + } + let active = 0 + let maximumActive = 0 + const runDryRun = vi.fn(async () => { + active += 1 + maximumActive = Math.max(maximumActive, active) + await new Promise((resolve) => setTimeout(resolve, 20)) + active -= 1 + return { success: true, durationMs: 20 } + }) + + const result = await runRegistryMonitor({ + directory, + previousState, + mode: "weekly", + now: NOW, + fetchJson: vi.fn(), + runDryRun, + }) + + expect(result.run.totals.dryRuns).toBe(6) + expect(maximumActive).toBe(4) + }) +}) diff --git a/apps/v4/lib/registry-health/monitor.ts b/apps/v4/lib/registry-health/monitor.ts new file mode 100644 index 0000000000..f1a2314b90 --- /dev/null +++ b/apps/v4/lib/registry-health/monitor.ts @@ -0,0 +1,566 @@ +import { registryItemSchema, registrySchema } from "shadcn/schema" + +import { + normalizeRegistryName, + type RegistryDirectoryEntry, +} from "../registry-directory" +import { fetchRegistryJson, type RegistryJsonResult } from "./network" +import { + REGISTRY_HEALTH_SCHEMA_VERSION, + REGISTRY_HEALTH_SCORE_VERSION, + registryHealthSnapshotSchema, + registryMonitorRunSchema, + registryMonitorStateSchema, + type RegistryDryRunObservation, + type RegistryHealthDailyBucket, + type RegistryHealthSnapshot, + type RegistryIndexObservation, + type RegistryItemObservation, + type RegistryMonitorEntryState, + type RegistryMonitorOutput, + type RegistryMonitorRun, + type RegistryMonitorState, +} from "./schema" +import { calculateGlobalMeans, calculateRegistryHealth } from "./score" +import { createRegistryMonitorEntryState } from "./state" + +const DAY_MS = 24 * 60 * 60 * 1000 +const RECENT_INDEX_RETENTION_MS = 8 * DAY_MS +const DAILY_CHECK_INTERVAL_MS = 20 * 60 * 60 * 1000 +const WEEKLY_CHECK_INTERVAL_MS = 6 * DAY_MS +const MAX_DRY_RUN_CONCURRENCY = 4 + +export type RegistryMonitorMode = RegistryMonitorRun["mode"] + +export type RegistryDryRunResult = { + success: boolean + failureCode?: string + durationMs: number +} + +function createDailyBucket(date: string) { + return { + date, + availabilitySuccesses: 0, + availabilityObservations: 0, + challengeObservations: 0, + schemaSuccesses: 0, + schemaObservations: 0, + itemSuccesses: 0, + itemObservations: 0, + dryRunSuccesses: 0, + dryRunObservations: 0, + } satisfies RegistryHealthDailyBucket +} + +function getDailyBucket(state: RegistryMonitorEntryState, now: Date) { + const date = now.toISOString().slice(0, 10) + let bucket = state.daily.find((entry) => entry.date === date) + + if (!bucket) { + bucket = createDailyBucket(date) + state.daily.push(bucket) + } + + return bucket +} + +function trimState(state: RegistryMonitorEntryState, now: Date) { + state.recentIndex = state.recentIndex + .filter( + (observation) => + now.getTime() - new Date(observation.checkedAt).getTime() < + RECENT_INDEX_RETENTION_MS + ) + .slice(-256) + state.recentDryRuns = state.recentDryRuns.slice(-12) + state.daily = state.daily + .filter( + (bucket) => + now.getTime() - new Date(`${bucket.date}T00:00:00.000Z`).getTime() < + 31 * DAY_MS + ) + .toSorted((a, b) => a.date.localeCompare(b.date)) +} + +function isJsonMediaType(contentType: string) { + const mediaType = contentType.split(";", 1)[0].trim().toLowerCase() + return ( + mediaType === "application/json" || + mediaType === "text/json" || + mediaType.endsWith("+json") + ) +} + +function getIndexObservation( + result: RegistryJsonResult, + entry: RegistryDirectoryEntry, + now: Date +) { + if (!result.ok) { + return { + observation: { + checkedAt: now.toISOString(), + outcome: result.botChallenge + ? ("bot_challenge" as const) + : result.reachable + ? ("reachable" as const) + : ("unreachable" as const), + status: result.status, + failureCode: result.failureCode, + durationMs: result.durationMs, + responseSize: result.responseSize, + redirectCount: result.redirectCount, + schemaValid: result.reachable ? false : undefined, + contentTypeJson: result.contentType + ? isJsonMediaType(result.contentType) + : undefined, + } satisfies RegistryIndexObservation, + itemNames: null, + } + } + + const parsed = registrySchema.safeParse(result.json) + const itemNames = parsed.success + ? parsed.data.items.map((item) => item.name) + : null + const duplicateNames = itemNames + ? new Set(itemNames).size !== itemNames.length + : undefined + const nameMatches = parsed.success + ? normalizeRegistryName(parsed.data.name) === + normalizeRegistryName(entry.name) + : undefined + + return { + observation: { + checkedAt: now.toISOString(), + outcome: "reachable" as const, + status: result.status, + failureCode: parsed.success ? undefined : "invalid_schema", + durationMs: result.durationMs, + responseSize: result.responseSize, + redirectCount: result.redirectCount, + schemaValid: parsed.success, + contentTypeJson: isJsonMediaType(result.contentType), + duplicateNames, + nameMatches, + itemCount: itemNames?.length, + } satisfies RegistryIndexObservation, + itemNames, + } +} + +function recordIndexObservation( + state: RegistryMonitorEntryState, + observation: RegistryIndexObservation, + itemNames: string[] | null, + now: Date +) { + state.recentIndex.push(observation) + const bucket = getDailyBucket(state, now) + + if (observation.outcome !== "bot_challenge") { + if (observation.outcome === "unreachable") { + state.consecutiveSuccessfulIndexes = 0 + state.consecutiveIndexFailures += 1 + state.availabilityOutageSince ??= + state.lastSuccessfulCheck ?? observation.checkedAt + const outageDuration = + new Date(observation.checkedAt).getTime() - + new Date(state.availabilityOutageSince).getTime() + const outageRequiresRecovery = outageDuration >= DAY_MS + if (state.consecutiveIndexFailures >= 3 || outageRequiresRecovery) { + state.availabilityRecoveryRequired = true + } + } else { + const outageDuration = state.availabilityOutageSince + ? new Date(observation.checkedAt).getTime() - + new Date(state.availabilityOutageSince).getTime() + : 0 + const outageRequiresRecovery = outageDuration >= DAY_MS + state.consecutiveIndexFailures = 0 + + if (observation.schemaValid) { + state.consecutiveSuccessfulIndexes += 1 + if ( + (state.availabilityRecoveryRequired || outageRequiresRecovery) && + state.consecutiveSuccessfulIndexes < 2 + ) { + state.availabilityRecoveryRequired = true + } else { + state.availabilityRecoveryRequired = false + delete state.availabilityOutageSince + } + } else { + state.consecutiveSuccessfulIndexes = 0 + if (state.availabilityRecoveryRequired || outageRequiresRecovery) { + state.availabilityRecoveryRequired = true + } else { + delete state.availabilityOutageSince + } + } + } + } + + if (observation.outcome === "bot_challenge") { + bucket.challengeObservations += 1 + } else { + bucket.availabilityObservations += 1 + bucket.availabilitySuccesses += observation.outcome === "reachable" ? 1 : 0 + } + + if (observation.outcome === "reachable") { + bucket.schemaObservations += 1 + bucket.schemaSuccesses += observation.schemaValid ? 1 : 0 + } + + if (observation.outcome === "reachable") { + state.latestHygiene = { + contentTypeJson: observation.contentTypeJson ?? false, + noDuplicateNames: + observation.duplicateNames === undefined + ? null + : !observation.duplicateNames, + nameMatches: observation.nameMatches ?? null, + } + } + + if (observation.schemaValid && itemNames) { + state.itemNames = itemNames + state.lastSuccessfulCheck = observation.checkedAt + } +} + +function getItemNamesForDailyCheck(state: RegistryMonitorEntryState) { + if (state.itemNames.length === 0) return [] + + const count = Math.max(1, Math.ceil(state.itemNames.length / 30)) + const selected = Array.from({ length: count }, (_, offset) => { + const index = (state.itemCursor + offset) % state.itemNames.length + return state.itemNames[index] + }) + state.itemCursor = (state.itemCursor + count) % state.itemNames.length + return selected +} + +function recordItemObservation( + state: RegistryMonitorEntryState, + observation: RegistryItemObservation, + now: Date +) { + if (observation.failureCode === "bot_challenge") return + + const bucket = getDailyBucket(state, now) + bucket.itemObservations += 1 + bucket.itemSuccesses += observation.success ? 1 : 0 +} + +function getNextDryRunItem(state: RegistryMonitorEntryState) { + if (state.itemNames.length === 0) return null + + const previousItem = state.recentDryRuns.at(-1)?.item + const previousIndex = previousItem + ? state.itemNames.indexOf(previousItem) + : -1 + + return state.itemNames[(previousIndex + 1) % state.itemNames.length] +} + +function recordDryRunObservation( + state: RegistryMonitorEntryState, + observation: RegistryDryRunObservation, + now: Date +) { + state.recentDryRuns.push(observation) + const bucket = getDailyBucket(state, now) + bucket.dryRunObservations += 1 + bucket.dryRunSuccesses += observation.success ? 1 : 0 +} + +function replaceRegistryItem(url: string, item: string) { + return url.replace("{name}", item) +} + +function hasIntervalElapsed( + lastRunAt: string | undefined, + now: Date, + intervalMs: number +) { + if (!lastRunAt) return true + + const elapsed = now.getTime() - new Date(lastRunAt).getTime() + return elapsed < 0 || elapsed >= intervalMs +} + +function getChecks( + mode: RegistryMonitorMode, + now: Date, + previousState?: RegistryMonitorState | null +) { + if (mode === "all") { + return { index: true, items: true, dryRun: true } + } + + if (mode !== "auto") { + return { + index: mode === "hourly", + items: mode === "daily", + dryRun: mode === "weekly", + } + } + + return { + index: true, + items: hasIntervalElapsed( + previousState?.lastDailyRunAt, + now, + DAILY_CHECK_INTERVAL_MS + ), + dryRun: hasIntervalElapsed( + previousState?.lastWeeklyRunAt, + now, + WEEKLY_CHECK_INTERVAL_MS + ), + } +} + +function createConcurrencyLimit(concurrency: number) { + let active = 0 + const waiting: Array<() => void> = [] + + return async function limit(task: () => Promise) { + while (active >= concurrency) { + await new Promise((resolve) => waiting.push(resolve)) + } + + active += 1 + try { + return await task() + } finally { + active -= 1 + waiting.shift()?.() + } + } +} + +async function mapWithConcurrency( + values: T[], + concurrency: number, + task: (value: T) => Promise +) { + let cursor = 0 + + async function worker() { + while (cursor < values.length) { + const index = cursor + cursor += 1 + await task(values[index]) + } + } + + await Promise.all( + Array.from({ length: Math.min(concurrency, values.length) }, () => worker()) + ) +} + +function groupByHost(directory: RegistryDirectoryEntry[]) { + const groups = new Map() + + for (const entry of directory) { + let host = entry.name + try { + host = new URL(entry.url).host + } catch { + host = entry.name + } + + const group = groups.get(host) ?? [] + group.push(entry) + groups.set(host, group) + } + + return [...groups.values()] +} + +export async function runRegistryMonitor({ + directory, + previousState, + mode = "auto", + now = new Date(), + fetchJson = fetchRegistryJson, + runDryRun, + concurrency = 12, +}: { + directory: RegistryDirectoryEntry[] + previousState?: RegistryMonitorState | null + mode?: RegistryMonitorMode + now?: Date + fetchJson?: typeof fetchRegistryJson + runDryRun: (options: { + namespace: string + item: string + registryUrl: string + }) => Promise + concurrency?: number +}): Promise { + const startedAt = now.toISOString() + const checks = getChecks(mode, now, previousState) + const state: RegistryMonitorState = { + schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION, + scoreVersion: REGISTRY_HEALTH_SCORE_VERSION, + updatedAt: now.toISOString(), + ...(previousState?.lastDailyRunAt + ? { lastDailyRunAt: previousState.lastDailyRunAt } + : {}), + ...(previousState?.lastWeeklyRunAt + ? { lastWeeklyRunAt: previousState.lastWeeklyRunAt } + : {}), + registries: {}, + } + const limitDryRun = createConcurrencyLimit(MAX_DRY_RUN_CONCURRENCY) + const runResults: RegistryMonitorRun["results"] = {} + const totals = { + registries: directory.length, + reachable: 0, + unavailable: 0, + challenges: 0, + itemChecks: 0, + dryRuns: 0, + } + + for (const entry of directory) { + state.registries[entry.name] = structuredClone( + previousState?.registries[entry.name] ?? + createRegistryMonitorEntryState(now) + ) + runResults[entry.name] = { items: [] } + } + + await mapWithConcurrency( + groupByHost(directory), + concurrency, + async (group) => { + for (const entry of group) { + const entryState = state.registries[entry.name] + const result = runResults[entry.name] + + if (checks.index) { + const indexResult = await fetchJson( + replaceRegistryItem(entry.url, "registry") + ) + const { observation, itemNames } = getIndexObservation( + indexResult, + entry, + now + ) + recordIndexObservation(entryState, observation, itemNames, now) + result.index = observation + + if (observation.outcome === "reachable") totals.reachable += 1 + if (observation.outcome === "unreachable") totals.unavailable += 1 + if (observation.outcome === "bot_challenge") totals.challenges += 1 + } + + if (checks.items) { + for (const item of getItemNamesForDailyCheck(entryState)) { + const itemResult = await fetchJson( + replaceRegistryItem(entry.url, item) + ) + const parsed = itemResult.ok + ? registryItemSchema.safeParse(itemResult.json) + : null + const success = !!(parsed?.success && parsed.data.name === item) + const observation: RegistryItemObservation = { + checkedAt: now.toISOString(), + item, + success, + failureCode: success + ? undefined + : itemResult.ok + ? parsed?.success + ? "name_mismatch" + : "invalid_schema" + : itemResult.failureCode, + durationMs: itemResult.durationMs, + } + recordItemObservation(entryState, observation, now) + result.items.push(observation) + totals.itemChecks += 1 + if (!itemResult.ok && itemResult.botChallenge) { + totals.challenges += 1 + } + } + } + + const dryRunItem = checks.dryRun ? getNextDryRunItem(entryState) : null + if (dryRunItem) { + const dryRunResult = await limitDryRun(() => + runDryRun({ + namespace: entry.name, + item: dryRunItem, + registryUrl: entry.url, + }) + ) + const observation: RegistryDryRunObservation = { + checkedAt: now.toISOString(), + item: dryRunItem, + ...dryRunResult, + } + recordDryRunObservation(entryState, observation, now) + result.dryRun = observation + totals.dryRuns += 1 + } + + trimState(entryState, now) + } + } + ) + + if (checks.items) { + state.lastDailyRunAt = now.toISOString() + } + if (checks.dryRun) { + state.lastWeeklyRunAt = now.toISOString() + } + + const registryUrls = Object.fromEntries( + directory.map((entry) => [entry.name, entry.url]) + ) + const globalMeans = calculateGlobalMeans(state, registryUrls, now) + const snapshot: RegistryHealthSnapshot = { + schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION, + scoreVersion: REGISTRY_HEALTH_SCORE_VERSION, + generatedAt: now.toISOString(), + globalMeans, + registries: {}, + } + + for (const entry of directory) { + const entryState = state.registries[entry.name] + const health = calculateRegistryHealth({ + state: entryState, + registryUrl: entry.url, + globalMeans, + now, + }) + entryState.status = health.status + snapshot.registries[entry.name] = health + } + + const run: RegistryMonitorRun = { + schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION, + startedAt, + completedAt: new Date().toISOString(), + mode, + totals, + results: runResults, + diagnostics: [], + } + + return { + state: registryMonitorStateSchema.parse(state), + snapshot: registryHealthSnapshotSchema.parse(snapshot), + run: registryMonitorRunSchema.parse(run), + } +} diff --git a/apps/v4/lib/registry-health/network.test.ts b/apps/v4/lib/registry-health/network.test.ts new file mode 100644 index 0000000000..2d67d6e4d5 --- /dev/null +++ b/apps/v4/lib/registry-health/network.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from "vitest" + +import { + createGuardedConnector, + detectBotChallenge, + fetchRegistryJson, + isForbiddenAddress, + validateRegistryUrl, +} from "./network" + +describe("network destination safety", () => { + it.each([ + "127.0.0.1", + "10.0.0.1", + "168.63.129.16", + "169.254.169.254", + "192.0.2.1", + "192.88.99.1", + "192.168.1.1", + "::1", + "64:ff9b::7f00:1", + "100::", + "2001::1", + "2002:7f00:1::", + "fe80::1", + "fec0::1", + "fd00:ec2::254", + "::ffff:127.0.0.1", + ])("rejects non-public address %s", (address) => { + expect(isForbiddenAddress(address)).toBe(true) + }) + + it.each(["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"])( + "allows public address %s", + (address) => { + expect(isForbiddenAddress(address)).toBe(false) + } + ) + + it("rejects unsupported protocols and embedded credentials", () => { + expect(() => validateRegistryUrl("file:///etc/passwd")).toThrow( + "unsupported_protocol" + ) + expect(() => + validateRegistryUrl("https://user:secret@example.com/registry.json") + ).toThrow("embedded_credentials") + }) + + it("pins the validated address used by the socket connector", async () => { + const connect = vi.fn((_options, callback) => callback(null, {})) + const connector = createGuardedConnector({ + resolve: async () => [{ address: "203.0.114.10", family: 4 }], + connect, + }) + + await new Promise((resolve, reject) => { + connector( + { + hostname: "registry.example.com", + protocol: "https:", + port: "443", + }, + (error, _socket) => (error ? reject(error) : resolve()) + ) + }) + + expect(connect).toHaveBeenCalledWith( + expect.objectContaining({ + hostname: "203.0.114.10", + servername: "registry.example.com", + }), + expect.any(Function) + ) + }) + + it("rejects a hostname when DNS includes a private address", async () => { + const connector = createGuardedConnector({ + resolve: async () => [ + { address: "203.0.114.10", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ], + connect: vi.fn(), + }) + + await expect( + new Promise((resolve, reject) => { + connector( + { + hostname: "registry.example.com", + protocol: "https:", + port: "443", + }, + (error, _socket) => (error ? reject(error) : resolve()) + ) + }) + ).rejects.toThrow("forbidden_address") + }) +}) + +describe("fetchRegistryJson", () => { + const dispatcher = { + close: vi.fn(async () => {}), + } + + it("validates every manual redirect target", async () => { + const fetchImpl = vi.fn( + async () => + new Response(null, { + status: 302, + headers: { location: "http://127.0.0.1/registry.json" }, + }) + ) + + await expect( + fetchRegistryJson("https://example.com/registry.json", { + fetchImpl, + createDispatcher: () => dispatcher as never, + attempts: 1, + }) + ).resolves.toMatchObject({ + ok: false, + failureCode: "forbidden_address", + }) + }) + + it("shares one timeout budget across redirects", async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-08-24T12:00:00.000Z")) + + try { + const fetchImpl = vi + .fn() + .mockImplementationOnce(async () => { + vi.setSystemTime(new Date("2026-08-24T12:00:00.080Z")) + return new Response(null, { + status: 302, + headers: { location: "https://example.com/final.json" }, + }) + }) + .mockImplementationOnce( + async (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")) + }) + }) + ) + + const resultPromise = fetchRegistryJson( + "https://example.com/registry.json", + { + fetchImpl, + createDispatcher: () => dispatcher as never, + attempts: 1, + timeoutMs: 100, + } + ) + + await vi.advanceTimersByTimeAsync(20) + + await expect(resultPromise).resolves.toMatchObject({ + ok: false, + failureCode: "timeout", + }) + expect(fetchImpl).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it("retries a retryable response and honors Retry-After", async () => { + const wait = vi.fn(async () => {}) + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response("busy", { + status: 429, + headers: { "retry-after": "1" }, + }) + ) + .mockResolvedValueOnce( + new Response('{"name":"acme"}', { + status: 200, + headers: { "content-type": "application/json" }, + }) + ) + + const result = await fetchRegistryJson( + "https://example.com/registry.json", + { + fetchImpl, + createDispatcher: () => dispatcher as never, + wait, + } + ) + + expect(result.ok).toBe(true) + expect(fetchImpl).toHaveBeenCalledTimes(2) + expect(wait).toHaveBeenCalledWith(1000) + }) + + it("enforces the response-size limit", async () => { + const result = await fetchRegistryJson( + "https://example.com/registry.json", + { + fetchImpl: async () => new Response("too large"), + createDispatcher: () => dispatcher as never, + maximumBytes: 3, + attempts: 1, + } + ) + + expect(result).toMatchObject({ + ok: false, + failureCode: "body_too_large", + }) + }) +}) + +describe("detectBotChallenge", () => { + it("recognizes a provider challenge page", () => { + expect( + detectBotChallenge({ + status: 403, + contentType: "text/html", + server: "cloudflare", + body: "Just a moment...
", + }) + ).toBe(true) + }) + + it("does not classify an ordinary JSON 403 as a challenge", () => { + expect( + detectBotChallenge({ + status: 403, + contentType: "application/json", + server: "", + body: '{"error":"forbidden"}', + }) + ).toBe(false) + }) +}) diff --git a/apps/v4/lib/registry-health/network.ts b/apps/v4/lib/registry-health/network.ts new file mode 100644 index 0000000000..b763892a63 --- /dev/null +++ b/apps/v4/lib/registry-health/network.ts @@ -0,0 +1,586 @@ +import { lookup } from "node:dns/promises" +import { isIP } from "node:net" +import { + Agent, + buildConnector, + fetch as undiciFetch, + type Dispatcher, +} from "undici" + +const DEFAULT_TIMEOUT_MS = 10_000 +const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024 +const DEFAULT_MAX_REDIRECTS = 5 +const MAX_RETRY_AFTER_MS = 30_000 + +type ResolvedAddress = { + address: string + family: number +} + +type ResolveHostname = (hostname: string) => Promise +type Connector = ReturnType + +type FetchLike = ( + url: string, + init: RequestInit & { dispatcher?: Dispatcher } +) => Promise + +export type RegistryJsonResult = + | { + ok: true + json: unknown + status: number + durationMs: number + responseSize: number + contentType: string + redirectCount: number + finalUrl: string + } + | { + ok: false + failureCode: string + reachable: boolean + botChallenge: boolean + status?: number + durationMs: number + responseSize?: number + contentType?: string + redirectCount: number + finalUrl: string + } + +type RegistryJsonAttemptResult = + | Extract + | (Extract & { + retryAfterMs?: number + }) + +function parseIpv4(address: string) { + if (isIP(address) !== 4) return null + + return address.split(".").map(Number) +} + +function parseIpv6(address: string) { + if (address.includes("%") || isIP(address) !== 6) return null + + let value = address.toLowerCase() + const ipv4Match = value.match(/(\d+\.\d+\.\d+\.\d+)$/) + + if (ipv4Match) { + const ipv4 = parseIpv4(ipv4Match[1]) + if (!ipv4) return null + value = value.replace( + ipv4Match[1], + `${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${( + (ipv4[2] << 8) | + ipv4[3] + ).toString(16)}` + ) + } + + const [left = "", right = ""] = value.split("::") + const leftParts = left ? left.split(":") : [] + const rightParts = right ? right.split(":") : [] + const missing = 8 - leftParts.length - rightParts.length + const parts = value.includes("::") + ? [ + ...leftParts, + ...Array.from({ length: missing }, () => "0"), + ...rightParts, + ] + : leftParts + + if (parts.length !== 8) return null + + const parsed = parts.map((part) => Number.parseInt(part || "0", 16)) + return parsed.every((part) => Number.isInteger(part) && part <= 0xffff) + ? parsed + : null +} + +function isForbiddenIpv4(address: string) { + const parts = parseIpv4(address) + if (!parts) return true + + const [a, b, c] = parts + + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0 && c === 0) || + (a === 192 && b === 0 && c === 2) || + (a === 192 && b === 88 && c === 99) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) || + (a === 168 && b === 63 && c === 129 && parts[3] === 16) || + a >= 224 + ) +} + +function isForbiddenIpv6(address: string) { + const parts = parseIpv6(address) + if (!parts) return true + + const isUnspecified = parts.every((part) => part === 0) + const isLoopback = + parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1 + const isUniqueLocal = (parts[0] & 0xfe00) === 0xfc00 + const isLinkLocal = (parts[0] & 0xffc0) === 0xfe80 + const isSiteLocal = (parts[0] & 0xffc0) === 0xfec0 + const isMulticast = (parts[0] & 0xff00) === 0xff00 + const isDocumentation = parts[0] === 0x2001 && parts[1] === 0x0db8 + const isProtocolAssignment = + parts[0] === 0x2001 && parts[1] >= 0 && parts[1] <= 0x01ff + const isSixToFour = parts[0] === 0x2002 + const isDiscardOnly = + parts[0] === 0x0100 && parts.slice(1, 4).every((part) => part === 0) + const isNat64 = + (parts[0] === 0x0064 && + parts[1] === 0xff9b && + parts.slice(2, 6).every((part) => part === 0)) || + (parts[0] === 0x0064 && parts[1] === 0xff9b && parts[2] === 0x0001) + const isIpv4Mapped = + parts.slice(0, 5).every((part) => part === 0) && parts[5] === 0xffff + const isIpv4Compatible = parts.slice(0, 6).every((part) => part === 0) + + if (isIpv4Mapped) { + return isForbiddenIpv4( + `${parts[6] >> 8}.${parts[6] & 255}.${parts[7] >> 8}.${parts[7] & 255}` + ) + } + + return ( + isUnspecified || + isLoopback || + isUniqueLocal || + isLinkLocal || + isSiteLocal || + isMulticast || + isDocumentation || + isProtocolAssignment || + isSixToFour || + isDiscardOnly || + isNat64 || + isIpv4Compatible + ) +} + +export function isForbiddenAddress(address: string) { + const normalized = address.replace(/^\[|\]$/g, "") + const family = isIP(normalized) + + if (family === 4) return isForbiddenIpv4(normalized) + if (family === 6) return isForbiddenIpv6(normalized) + + return true +} + +export function validateRegistryUrl(input: string) { + const url = new URL(input) + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("unsupported_protocol") + } + + if (url.username || url.password) { + throw new Error("embedded_credentials") + } + + const hostname = url.hostname.replace(/^\[|\]$/g, "") + if (isIP(hostname) && isForbiddenAddress(hostname)) { + throw new Error("forbidden_address") + } + + return url +} + +const defaultResolve: ResolveHostname = (hostname) => + lookup(hostname, { all: true, verbatim: true }) + +export function createGuardedConnector({ + resolve = defaultResolve, + connect = buildConnector({ timeout: DEFAULT_TIMEOUT_MS }), +}: { + resolve?: ResolveHostname + connect?: Connector +} = {}): Connector { + return (options, callback) => { + const hostname = options.hostname.replace(/^\[|\]$/g, "") + + void (async () => { + const addresses = isIP(hostname) + ? [{ address: hostname, family: isIP(hostname) }] + : await resolve(hostname) + + if ( + addresses.length === 0 || + addresses.some(({ address }) => isForbiddenAddress(address)) + ) { + throw new Error("forbidden_address") + } + + const [{ address }] = addresses + connect( + { + ...options, + hostname: address, + servername: options.servername ?? hostname, + }, + callback + ) + })().catch((error) => + callback(error instanceof Error ? error : new Error("dns_failure"), null) + ) + } +} + +function createGuardedDispatcher() { + return new Agent({ + connect: createGuardedConnector(), + headersTimeout: DEFAULT_TIMEOUT_MS, + bodyTimeout: DEFAULT_TIMEOUT_MS, + }) +} + +async function readLimitedBody(response: Response, maximumBytes: number) { + if (!response.body) return { text: "", size: 0 } + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + + while (true) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + + if (size > maximumBytes) { + await reader.cancel() + throw new Error("body_too_large") + } + + chunks.push(value) + } + + const body = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + + return { text: new TextDecoder().decode(body), size } +} + +export function detectBotChallenge({ + status, + contentType, + server, + body, +}: { + status: number + contentType: string + server: string + body: string +}) { + const content = body.toLowerCase() + const provider = server.toLowerCase() + const isHtml = contentType.toLowerCase().includes("text/html") + const marker = [ + "cf-chl-", + "challenge-platform", + "just a moment", + "attention required", + "vercel security checkpoint", + "captcha", + ].some((value) => content.includes(value)) + + return ( + marker && + (isHtml || status === 403 || status === 429) && + (provider.includes("cloudflare") || + provider.includes("vercel") || + content.includes("challenge") || + content.includes("captcha")) + ) +} + +function isRedirect(status: number) { + return [301, 302, 303, 307, 308].includes(status) +} + +function isRetryableStatus(status: number) { + return status === 408 || status === 429 || status >= 500 +} + +function parseRetryAfter(value: string | null, now: number) { + if (!value) return null + + const seconds = Number(value) + const delay = Number.isFinite(seconds) + ? seconds * 1000 + : new Date(value).getTime() - now + + if (!Number.isFinite(delay) || delay < 0) return null + return Math.min(delay, MAX_RETRY_AFTER_MS) +} + +function sleep(milliseconds: number) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +async function fetchOnce( + input: string, + { + timeoutMs, + maximumBytes, + maximumRedirects, + fetchImpl, + createDispatcher, + }: { + timeoutMs: number + maximumBytes: number + maximumRedirects: number + fetchImpl: FetchLike + createDispatcher: () => Dispatcher + } +) { + const startedAt = Date.now() + let currentUrl = validateRegistryUrl(input) + let redirectCount = 0 + + while (true) { + validateRegistryUrl(currentUrl.toString()) + const remainingMs = timeoutMs - (Date.now() - startedAt) + if (remainingMs <= 0) { + return { + ok: false, + failureCode: "timeout", + reachable: false, + botChallenge: false, + durationMs: Date.now() - startedAt, + redirectCount, + finalUrl: currentUrl.toString(), + } satisfies RegistryJsonAttemptResult + } + + const dispatcher = createDispatcher() + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), remainingMs) + + try { + const response = await fetchImpl(currentUrl.toString(), { + dispatcher, + redirect: "manual", + signal: controller.signal, + headers: { + Accept: "application/json", + "User-Agent": "shadcn-registry-health/1.0", + }, + }) + + if (isRedirect(response.status)) { + const location = response.headers.get("location") + await response.body?.cancel() + if (!location) { + return { + ok: false, + failureCode: "redirect_without_location", + reachable: false, + botChallenge: false, + status: response.status, + durationMs: Date.now() - startedAt, + redirectCount, + finalUrl: currentUrl.toString(), + } satisfies RegistryJsonAttemptResult + } + + redirectCount += 1 + if (redirectCount > maximumRedirects) { + return { + ok: false, + failureCode: "too_many_redirects", + reachable: false, + botChallenge: false, + status: response.status, + durationMs: Date.now() - startedAt, + redirectCount, + finalUrl: currentUrl.toString(), + } satisfies RegistryJsonAttemptResult + } + + currentUrl = validateRegistryUrl( + new URL(location, currentUrl).toString() + ) + continue + } + + let body: Awaited> + try { + body = await readLimitedBody(response, maximumBytes) + } catch { + return { + ok: false, + failureCode: "body_too_large", + reachable: response.ok, + botChallenge: false, + status: response.status, + durationMs: Date.now() - startedAt, + redirectCount, + finalUrl: currentUrl.toString(), + } satisfies RegistryJsonAttemptResult + } + + const contentType = response.headers.get("content-type") ?? "" + const botChallenge = detectBotChallenge({ + status: response.status, + contentType, + server: response.headers.get("server") ?? "", + body: body.text, + }) + + if (botChallenge) { + return { + ok: false, + failureCode: "bot_challenge", + reachable: false, + botChallenge: true, + status: response.status, + durationMs: Date.now() - startedAt, + responseSize: body.size, + contentType, + redirectCount, + finalUrl: currentUrl.toString(), + } satisfies RegistryJsonAttemptResult + } + + if (!response.ok) { + return { + ok: false, + failureCode: `http_${response.status}`, + reachable: false, + botChallenge: false, + status: response.status, + durationMs: Date.now() - startedAt, + responseSize: body.size, + contentType, + redirectCount, + finalUrl: currentUrl.toString(), + retryAfterMs: + parseRetryAfter(response.headers.get("retry-after"), Date.now()) ?? + undefined, + } satisfies RegistryJsonAttemptResult + } + + try { + return { + ok: true, + json: JSON.parse(body.text), + status: response.status, + durationMs: Date.now() - startedAt, + responseSize: body.size, + contentType, + redirectCount, + finalUrl: currentUrl.toString(), + } satisfies RegistryJsonAttemptResult + } catch { + return { + ok: false, + failureCode: "invalid_json", + reachable: true, + botChallenge: false, + status: response.status, + durationMs: Date.now() - startedAt, + responseSize: body.size, + contentType, + redirectCount, + finalUrl: currentUrl.toString(), + } satisfies RegistryJsonAttemptResult + } + } catch (error) { + const messages = [ + error instanceof Error ? error.message : "", + error instanceof Error && error.cause instanceof Error + ? error.cause.message + : "", + ] + const guardedFailure = [ + "embedded_credentials", + "forbidden_address", + "unsupported_protocol", + ].find((failureCode) => messages.includes(failureCode)) + const failureCode = + guardedFailure ?? + (error instanceof Error && error.name === "AbortError" + ? "timeout" + : "transport_error") + + return { + ok: false, + failureCode, + reachable: false, + botChallenge: false, + durationMs: Date.now() - startedAt, + redirectCount, + finalUrl: currentUrl.toString(), + } satisfies RegistryJsonAttemptResult + } finally { + clearTimeout(timeout) + await dispatcher.close() + } + } +} + +export async function fetchRegistryJson( + input: string, + options: { + attempts?: number + timeoutMs?: number + maximumBytes?: number + maximumRedirects?: number + fetchImpl?: FetchLike + createDispatcher?: () => Dispatcher + wait?: (milliseconds: number) => Promise + random?: () => number + } = {} +): Promise { + const attempts = options.attempts ?? 3 + const wait = options.wait ?? sleep + const random = options.random ?? Math.random + let result: Awaited> | undefined + + for (let attempt = 0; attempt < attempts; attempt += 1) { + result = await fetchOnce(input, { + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maximumBytes: options.maximumBytes ?? DEFAULT_MAX_BODY_BYTES, + maximumRedirects: options.maximumRedirects ?? DEFAULT_MAX_REDIRECTS, + fetchImpl: options.fetchImpl ?? (undiciFetch as unknown as FetchLike), + createDispatcher: options.createDispatcher ?? createGuardedDispatcher, + }) + + const retryable = + !result.ok && + !result.botChallenge && + (result.failureCode === "transport_error" || + result.failureCode === "timeout" || + (result.status !== undefined && isRetryableStatus(result.status))) + + if (!retryable || attempt === attempts - 1) { + return result + } + + await wait( + result.retryAfterMs ?? + Math.min(1000 * 2 ** attempt + random() * 250, 5000) + ) + } + + return result! +} diff --git a/apps/v4/lib/registry-health/schema.test.ts b/apps/v4/lib/registry-health/schema.test.ts new file mode 100644 index 0000000000..b083371b3e --- /dev/null +++ b/apps/v4/lib/registry-health/schema.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest" + +import { registryMonitorEntryStateSchema } from "./schema" + +describe("registryMonitorEntryStateSchema", () => { + it("migrates legacy availability recovery state from retained history", () => { + const failures = Array.from({ length: 3 }, (_, index) => ({ + checkedAt: `2026-08-16T0${index}:00:00.000Z`, + outcome: "unreachable" as const, + durationMs: 100, + redirectCount: 0, + })) + const recovery = { + checkedAt: "2026-08-24T11:00:00.000Z", + outcome: "reachable" as const, + durationMs: 100, + redirectCount: 0, + schemaValid: true, + } + + const state = registryMonitorEntryStateSchema.parse({ + firstObservedAt: "2026-08-01T00:00:00.000Z", + lastSuccessfulCheck: recovery.checkedAt, + status: "unavailable", + itemCursor: 0, + itemNames: [], + recentIndex: [...failures, recovery], + recentDryRuns: [], + daily: [], + latestHygiene: { + contentTypeJson: true, + noDuplicateNames: true, + nameMatches: true, + }, + }) + + expect(state).toMatchObject({ + consecutiveSuccessfulIndexes: 1, + consecutiveIndexFailures: 0, + availabilityOutageSince: failures[0].checkedAt, + availabilityRecoveryRequired: true, + }) + }) + + it("preserves the last success as the start of a legacy outage", () => { + const lastSuccessfulCheck = "2026-08-23T08:00:00.000Z" + const state = registryMonitorEntryStateSchema.parse({ + firstObservedAt: "2026-08-01T00:00:00.000Z", + lastSuccessfulCheck, + status: "degraded", + itemCursor: 0, + itemNames: [], + recentIndex: Array.from({ length: 3 }, (_, index) => ({ + checkedAt: `2026-08-24T0${index}:00:00.000Z`, + outcome: "unreachable" as const, + durationMs: 100, + redirectCount: 0, + })), + recentDryRuns: [], + daily: [], + latestHygiene: { + contentTypeJson: true, + noDuplicateNames: true, + nameMatches: true, + }, + }) + + expect(state).toMatchObject({ + consecutiveSuccessfulIndexes: 0, + consecutiveIndexFailures: 3, + availabilityOutageSince: lastSuccessfulCheck, + availabilityRecoveryRequired: true, + }) + }) +}) diff --git a/apps/v4/lib/registry-health/schema.ts b/apps/v4/lib/registry-health/schema.ts new file mode 100644 index 0000000000..aa9e864301 --- /dev/null +++ b/apps/v4/lib/registry-health/schema.ts @@ -0,0 +1,308 @@ +import { z } from "zod" + +import { registryNamespaceSchema } from "../registry-directory" + +export const REGISTRY_HEALTH_SCHEMA_VERSION = 1 as const +export const REGISTRY_HEALTH_SCORE_VERSION = 1 as const + +export const registryHealthStatusSchema = z.enum([ + "healthy", + "observing", + "degraded", + "unavailable", +]) + +export const registryHealthStatusReasonCodeSchema = z.enum([ + "healthy_thresholds", + "collecting_baseline", + "index_unavailable", + "recovery_pending", + "consecutive_index_failures", + "index_schema_invalid", + "item_validation_failures", + "dry_run_failures", +]) + +export const registryHealthBreakdownSchema = z + .object({ + reliability: z.number().min(0).max(45), + correctness: z.number().min(0).max(25), + installability: z.number().min(0).max(20), + hygiene: z.number().min(0).max(10), + }) + .strict() + +export const registryHealthSchema = z + .object({ + schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION), + scoreVersion: z.literal(REGISTRY_HEALTH_SCORE_VERSION), + status: registryHealthStatusSchema, + statusReason: z + .object({ + code: registryHealthStatusReasonCodeSchema, + message: z.string().min(1), + }) + .strict() + .optional(), + score: z.number().min(0).max(100), + breakdown: registryHealthBreakdownSchema, + availability7d: z.number().min(0).max(1), + availability30d: z.number().min(0).max(1), + monitoringLimited: z.boolean(), + firstObservedAt: z.string().datetime().optional(), + checkedAt: z.string().datetime(), + lastSuccessfulCheck: z.string().datetime().optional(), + hidden: z.boolean(), + }) + .strict() + +export const registryHealthGlobalMeansSchema = z + .object({ + availability7d: z.number().min(0).max(1), + availability30d: z.number().min(0).max(1), + indexSchema: z.number().min(0).max(1), + itemValidity: z.number().min(0).max(1), + dryRun: z.number().min(0).max(1), + }) + .strict() + +export const registryHealthSnapshotSchema = z + .object({ + schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION), + scoreVersion: z.literal(REGISTRY_HEALTH_SCORE_VERSION), + generatedAt: z.string().datetime(), + globalMeans: registryHealthGlobalMeansSchema, + registries: z.record(registryNamespaceSchema, registryHealthSchema), + }) + .strict() + +export const registryIndexObservationSchema = z + .object({ + checkedAt: z.string().datetime(), + outcome: z.enum(["reachable", "unreachable", "bot_challenge"]), + status: z.number().int().min(100).max(599).optional(), + failureCode: z.string().optional(), + durationMs: z.number().int().nonnegative(), + responseSize: z.number().int().nonnegative().optional(), + redirectCount: z.number().int().nonnegative(), + schemaValid: z.boolean().optional(), + contentTypeJson: z.boolean().optional(), + duplicateNames: z.boolean().optional(), + nameMatches: z.boolean().optional(), + itemCount: z.number().int().nonnegative().optional(), + }) + .strict() + +export const registryItemObservationSchema = z + .object({ + checkedAt: z.string().datetime(), + item: z.string(), + success: z.boolean(), + failureCode: z.string().optional(), + durationMs: z.number().int().nonnegative(), + }) + .strict() + +export const registryDryRunObservationSchema = z + .object({ + checkedAt: z.string().datetime(), + item: z.string(), + success: z.boolean(), + failureCode: z.string().optional(), + durationMs: z.number().int().nonnegative(), + }) + .strict() + +export const registryHealthDailyBucketSchema = z + .object({ + date: z.string().date(), + availabilitySuccesses: z.number().int().nonnegative(), + availabilityObservations: z.number().int().nonnegative(), + challengeObservations: z.number().int().nonnegative(), + schemaSuccesses: z.number().int().nonnegative(), + schemaObservations: z.number().int().nonnegative(), + itemSuccesses: z.number().int().nonnegative(), + itemObservations: z.number().int().nonnegative(), + dryRunSuccesses: z.number().int().nonnegative(), + dryRunObservations: z.number().int().nonnegative(), + }) + .strict() + +const registryMonitorEntryStateInputSchema = z + .object({ + firstObservedAt: z.string().datetime(), + lastSuccessfulCheck: z.string().datetime().optional(), + status: registryHealthStatusSchema, + consecutiveSuccessfulIndexes: z.number().int().nonnegative().optional(), + consecutiveIndexFailures: z.number().int().nonnegative().optional(), + availabilityOutageSince: z.string().datetime().optional(), + availabilityRecoveryRequired: z.boolean().optional(), + itemCursor: z.number().int().nonnegative(), + itemNames: z.array(z.string()), + recentIndex: z.array(registryIndexObservationSchema), + recentDryRuns: z.array(registryDryRunObservationSchema), + daily: z.array(registryHealthDailyBucketSchema), + latestHygiene: z + .object({ + contentTypeJson: z.boolean().nullable(), + noDuplicateNames: z.boolean().nullable(), + nameMatches: z.boolean().nullable(), + }) + .strict(), + }) + .strict() + +function getLegacyAvailabilityState( + entry: z.infer +) { + const observations = entry.recentIndex.filter( + (observation) => observation.outcome !== "bot_challenge" + ) + let cursor = observations.length - 1 + let consecutiveSuccessfulIndexes = 0 + + while (cursor >= 0) { + const observation = observations[cursor] + if (observation.outcome !== "reachable" || !observation.schemaValid) { + break + } + consecutiveSuccessfulIndexes += 1 + cursor -= 1 + } + + const failureEnd = cursor + let precedingFailures = 0 + while (cursor >= 0 && observations[cursor].outcome === "unreachable") { + precedingFailures += 1 + cursor -= 1 + } + + const consecutiveIndexFailures = + consecutiveSuccessfulIndexes === 0 ? precedingFailures : 0 + const recoveryRequired = + entry.status === "unavailable" || + (entry.status === "degraded" && precedingFailures >= 3) + const failureStart = + precedingFailures > 0 + ? observations[failureEnd - precedingFailures + 1] + : undefined + const outageStart = + consecutiveSuccessfulIndexes > 0 + ? failureStart?.checkedAt + : (entry.lastSuccessfulCheck ?? failureStart?.checkedAt) + const availabilityOutageSince = + recoveryRequired || consecutiveIndexFailures > 0 + ? (outageStart ?? entry.firstObservedAt) + : undefined + + return { + consecutiveSuccessfulIndexes, + consecutiveIndexFailures, + availabilityOutageSince, + availabilityRecoveryRequired: recoveryRequired, + } +} + +export const registryMonitorEntryStateSchema = + registryMonitorEntryStateInputSchema.transform((entry) => { + if ( + entry.consecutiveSuccessfulIndexes !== undefined && + entry.consecutiveIndexFailures !== undefined && + entry.availabilityRecoveryRequired !== undefined + ) { + return entry as typeof entry & { + consecutiveSuccessfulIndexes: number + consecutiveIndexFailures: number + availabilityRecoveryRequired: boolean + } + } + + return { + ...entry, + ...getLegacyAvailabilityState(entry), + } + }) + +export const registryMonitorStateSchema = z + .object({ + schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION), + scoreVersion: z.literal(REGISTRY_HEALTH_SCORE_VERSION), + updatedAt: z.string().datetime(), + lastDailyRunAt: z.string().datetime().optional(), + lastWeeklyRunAt: z.string().datetime().optional(), + registries: z.record( + registryNamespaceSchema, + registryMonitorEntryStateSchema + ), + }) + .strict() + +export const registryMonitorRunSchema = z + .object({ + schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION), + startedAt: z.string().datetime(), + completedAt: z.string().datetime(), + mode: z.enum(["auto", "hourly", "daily", "weekly", "all"]), + totals: z + .object({ + registries: z.number().int().nonnegative(), + reachable: z.number().int().nonnegative(), + unavailable: z.number().int().nonnegative(), + challenges: z.number().int().nonnegative(), + itemChecks: z.number().int().nonnegative(), + dryRuns: z.number().int().nonnegative(), + }) + .strict(), + results: z.record( + registryNamespaceSchema, + z + .object({ + index: registryIndexObservationSchema.optional(), + items: z.array(registryItemObservationSchema), + dryRun: registryDryRunObservationSchema.optional(), + }) + .strict() + ), + diagnostics: z.array(z.string()), + }) + .strict() + +export const registryMonitorOutputSchema = z + .object({ + state: registryMonitorStateSchema, + snapshot: registryHealthSnapshotSchema, + run: registryMonitorRunSchema, + }) + .strict() + +export type RegistryHealth = z.infer +export type RegistryHealthStatusReasonCode = z.infer< + typeof registryHealthStatusReasonCodeSchema +> +export type RegistryHealthBreakdown = z.infer< + typeof registryHealthBreakdownSchema +> +export type RegistryHealthGlobalMeans = z.infer< + typeof registryHealthGlobalMeansSchema +> +export type RegistryHealthSnapshot = z.infer< + typeof registryHealthSnapshotSchema +> +export type RegistryIndexObservation = z.infer< + typeof registryIndexObservationSchema +> +export type RegistryItemObservation = z.infer< + typeof registryItemObservationSchema +> +export type RegistryDryRunObservation = z.infer< + typeof registryDryRunObservationSchema +> +export type RegistryHealthDailyBucket = z.infer< + typeof registryHealthDailyBucketSchema +> +export type RegistryMonitorEntryState = z.infer< + typeof registryMonitorEntryStateSchema +> +export type RegistryMonitorState = z.infer +export type RegistryMonitorRun = z.infer +export type RegistryMonitorOutput = z.infer diff --git a/apps/v4/lib/registry-health/score.test.ts b/apps/v4/lib/registry-health/score.test.ts new file mode 100644 index 0000000000..8959244e06 --- /dev/null +++ b/apps/v4/lib/registry-health/score.test.ts @@ -0,0 +1,393 @@ +import { describe, expect, it } from "vitest" + +import type { + RegistryHealthDailyBucket, + RegistryIndexObservation, + RegistryMonitorEntryState, +} from "./schema" +import { + calculateRegistryHealth, + DEFAULT_GLOBAL_MEANS, + getRegistryHealthSignals, +} from "./score" + +const NOW = new Date("2026-08-24T12:00:00.000Z") + +function createDailyBucket(overrides: Partial = {}) { + return { + date: "2026-08-24", + availabilitySuccesses: 0, + availabilityObservations: 0, + challengeObservations: 0, + schemaSuccesses: 0, + schemaObservations: 0, + itemSuccesses: 0, + itemObservations: 0, + dryRunSuccesses: 0, + dryRunObservations: 0, + ...overrides, + } satisfies RegistryHealthDailyBucket +} + +function createIndexObservations( + count = 25, + outcome: RegistryIndexObservation["outcome"] = "reachable" +) { + return Array.from({ length: count }, (_, index) => ({ + checkedAt: new Date( + NOW.getTime() - (count - index) * 60 * 60 * 1000 + ).toISOString(), + outcome, + durationMs: 100, + redirectCount: 0, + schemaValid: outcome === "reachable", + })) satisfies RegistryIndexObservation[] +} + +function createState(overrides: Partial = {}) { + return { + firstObservedAt: "2026-08-22T10:00:00.000Z", + lastSuccessfulCheck: "2026-08-24T11:00:00.000Z", + status: "healthy", + consecutiveSuccessfulIndexes: 25, + consecutiveIndexFailures: 0, + availabilityRecoveryRequired: false, + itemCursor: 0, + itemNames: [], + recentIndex: createIndexObservations(), + recentDryRuns: [], + daily: [ + createDailyBucket({ + availabilitySuccesses: 25, + availabilityObservations: 25, + schemaSuccesses: 25, + schemaObservations: 25, + }), + ], + latestHygiene: { + contentTypeJson: null, + noDuplicateNames: null, + nameMatches: null, + }, + ...overrides, + } satisfies RegistryMonitorEntryState +} + +describe("calculateRegistryHealth", () => { + it("derives the published total from rounded components", () => { + const health = calculateRegistryHealth({ + state: createState(), + registryUrl: "https://example.com/r/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + + expect(health.score).toBe( + health.breakdown.reliability + + health.breakdown.correctness + + health.breakdown.installability + + health.breakdown.hygiene + ) + expect(health.score).toBeGreaterThanOrEqual(0) + expect(health.score).toBeLessThanOrEqual(100) + expect(health.statusReason).toEqual({ + code: "healthy_thresholds", + message: "Recent checks are within healthy thresholds", + }) + }) + + it("explains when baseline observations are still being collected", () => { + const health = calculateRegistryHealth({ + state: createState({ recentIndex: createIndexObservations(3) }), + registryUrl: "https://example.com/r/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + + expect(health.status).toBe("observing") + expect(health.statusReason).toEqual({ + code: "collecting_baseline", + message: "Collecting baseline data (3 of 24 checks)", + }) + }) + + it("gives unknown hygiene signals half credit", () => { + const health = calculateRegistryHealth({ + state: createState(), + registryUrl: "https://example.com/r/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + + expect(health.breakdown.hygiene).toBe(6.25) + }) + + it("uses a cadence-scaled prior for weekly dry runs", () => { + const state = createState({ + daily: [ + createDailyBucket({ + availabilitySuccesses: 25, + availabilityObservations: 25, + schemaSuccesses: 25, + schemaObservations: 25, + dryRunSuccesses: 1, + dryRunObservations: 1, + }), + ], + }) + const health = calculateRegistryHealth({ + state, + registryUrl: "https://example.com/r/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + + expect(health.breakdown.installability).toBe(18.5) + }) + + it("excludes challenge observations from availability", () => { + const state = createState({ + recentIndex: [ + ...createIndexObservations(), + { + checkedAt: NOW.toISOString(), + outcome: "bot_challenge", + durationMs: 100, + redirectCount: 0, + }, + ], + }) + const signals = getRegistryHealthSignals( + state, + "https://example.com/{name}.json", + NOW + ) + const health = calculateRegistryHealth({ + state, + registryUrl: "https://example.com/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + + expect(signals.availability7d).toEqual({ + successes: 25, + observations: 25, + }) + expect(health.monitoringLimited).toBe(true) + expect(health.status).toBe("healthy") + }) + + it("hides a registry only after seven days unavailable", () => { + const state = createState({ + firstObservedAt: "2026-08-01T00:00:00.000Z", + lastSuccessfulCheck: "2026-08-16T00:00:00.000Z", + status: "unavailable", + consecutiveSuccessfulIndexes: 0, + consecutiveIndexFailures: 8, + availabilityOutageSince: "2026-08-16T00:00:00.000Z", + availabilityRecoveryRequired: true, + recentIndex: createIndexObservations(8, "unreachable"), + }) + const health = calculateRegistryHealth({ + state, + registryUrl: "https://example.com/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + + expect(health.status).toBe("unavailable") + expect(health.statusReason).toEqual({ + code: "index_unavailable", + message: "No successful index check in the last 24 hours", + }) + expect(health.hidden).toBe(true) + }) + + it("keeps an unavailable registry visible before seven days", () => { + const state = createState({ + firstObservedAt: "2026-08-01T00:00:00.000Z", + lastSuccessfulCheck: "2026-08-20T00:00:00.000Z", + status: "unavailable", + consecutiveSuccessfulIndexes: 0, + consecutiveIndexFailures: 8, + availabilityOutageSince: "2026-08-20T00:00:00.000Z", + availabilityRecoveryRequired: true, + recentIndex: createIndexObservations(8, "unreachable"), + }) + const health = calculateRegistryHealth({ + state, + registryUrl: "https://example.com/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + + expect(health.status).toBe("unavailable") + expect(health.hidden).toBe(false) + }) + + it("keeps a long outage hidden through the first recovery success", () => { + const recovery = createIndexObservations(1)[0] + const state = createState({ + firstObservedAt: "2026-08-01T00:00:00.000Z", + lastSuccessfulCheck: recovery.checkedAt, + status: "unavailable", + consecutiveSuccessfulIndexes: 1, + consecutiveIndexFailures: 0, + availabilityOutageSince: "2026-08-16T00:00:00.000Z", + availabilityRecoveryRequired: true, + recentIndex: [...createIndexObservations(24, "unreachable"), recovery], + }) + + const health = calculateRegistryHealth({ + state, + registryUrl: "https://example.com/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + + expect(health.status).toBe("unavailable") + expect(health.statusReason?.code).toBe("recovery_pending") + expect(health.hidden).toBe(true) + }) + + it("requires two successful checks to recover from availability degradation", () => { + const history = createIndexObservations() + const failures = createIndexObservations(3, "unreachable") + const successes = createIndexObservations(2) + const state = createState({ + status: "degraded", + consecutiveSuccessfulIndexes: 1, + consecutiveIndexFailures: 0, + availabilityOutageSince: failures[0].checkedAt, + availabilityRecoveryRequired: true, + recentIndex: [...history, ...failures, successes[0]], + }) + + const recovering = calculateRegistryHealth({ + state, + registryUrl: "https://example.com/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + const recovered = calculateRegistryHealth({ + state: { + ...state, + consecutiveSuccessfulIndexes: 2, + availabilityOutageSince: undefined, + availabilityRecoveryRequired: false, + recentIndex: [...state.recentIndex, successes[1]], + }, + registryUrl: "https://example.com/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + + expect(recovering.status).toBe("degraded") + expect(recovering.statusReason).toEqual({ + code: "recovery_pending", + message: "Waiting for a second successful recovery check", + }) + expect(recovered.status).toBe("healthy") + }) + + it("explains each degraded status condition", () => { + const failures = calculateRegistryHealth({ + state: createState({ + consecutiveSuccessfulIndexes: 0, + consecutiveIndexFailures: 3, + availabilityOutageSince: "2026-08-24T09:00:00.000Z", + availabilityRecoveryRequired: true, + recentIndex: [ + ...createIndexObservations(), + ...createIndexObservations(3, "unreachable"), + ], + }), + registryUrl: "https://example.com/r/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + const observations = createIndexObservations() + const invalidSchema = calculateRegistryHealth({ + state: createState({ + recentIndex: [ + ...observations.slice(0, -1), + { ...observations.at(-1)!, schemaValid: false }, + ], + }), + registryUrl: "https://example.com/r/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + const invalidItems = calculateRegistryHealth({ + state: createState({ + daily: [ + createDailyBucket({ + availabilitySuccesses: 25, + availabilityObservations: 25, + schemaSuccesses: 25, + schemaObservations: 25, + itemSuccesses: 8, + itemObservations: 10, + }), + ], + }), + registryUrl: "https://example.com/r/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + const dryRuns = calculateRegistryHealth({ + state: createState({ + recentDryRuns: [ + { + checkedAt: "2026-08-17T12:00:00.000Z", + item: "button", + success: false, + durationMs: 100, + }, + { + checkedAt: "2026-08-24T12:00:00.000Z", + item: "button", + success: false, + durationMs: 100, + }, + ], + }), + registryUrl: "https://example.com/r/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + + expect(failures.statusReason).toEqual({ + code: "consecutive_index_failures", + message: "The registry index failed 3 consecutive checks", + }) + expect(invalidSchema.statusReason).toEqual({ + code: "index_schema_invalid", + message: "The registry index failed schema validation", + }) + expect(invalidItems.statusReason).toEqual({ + code: "item_validation_failures", + message: "Sampled registry items are failing validation", + }) + expect(dryRuns.statusReason).toEqual({ + code: "dry_run_failures", + message: "The last two CLI dry-run checks failed", + }) + }) + + it("recovers correctness degradation when its condition clears", () => { + const state = createState({ + status: "degraded", + recentIndex: createIndexObservations(), + }) + + const health = calculateRegistryHealth({ + state, + registryUrl: "https://example.com/{name}.json", + globalMeans: DEFAULT_GLOBAL_MEANS, + now: NOW, + }) + + expect(health.status).toBe("healthy") + }) +}) diff --git a/apps/v4/lib/registry-health/score.ts b/apps/v4/lib/registry-health/score.ts new file mode 100644 index 0000000000..7b6177aed0 --- /dev/null +++ b/apps/v4/lib/registry-health/score.ts @@ -0,0 +1,442 @@ +import { + REGISTRY_HEALTH_SCHEMA_VERSION, + REGISTRY_HEALTH_SCORE_VERSION, + type RegistryHealth, + type RegistryHealthGlobalMeans, + type RegistryMonitorEntryState, + type RegistryMonitorState, +} from "./schema" + +const DAY_MS = 24 * 60 * 60 * 1000 + +const DEFAULT_GLOBAL_MEANS: RegistryHealthGlobalMeans = { + availability7d: 0.85, + availability30d: 0.85, + indexSchema: 0.9, + itemValidity: 0.9, + dryRun: 0.9, +} + +const PRIOR_WEIGHTS = { + availability7d: 24, + availability30d: 24, + indexSchema: 12, + itemValidity: 10, + dryRun: 3, +} as const + +type RateSignal = { + successes: number + observations: number +} + +export type RegistryHealthSignals = { + availability7d: RateSignal + availability30d: RateSignal + indexSchema: RateSignal + itemValidity: RateSignal + dryRun: RateSignal + hygiene: { + https: boolean + contentTypeJson: boolean | null + noDuplicateNames: boolean | null + nameMatches: boolean | null + } +} + +function round(value: number, digits: number) { + const factor = 10 ** digits + return Math.round((value + Number.EPSILON) * factor) / factor +} + +function isWithinWindow(timestamp: string, now: Date, days: number) { + const age = now.getTime() - new Date(timestamp).getTime() + return age >= 0 && age < days * DAY_MS +} + +function isBucketWithinWindow(date: string, now: Date, days: number) { + return isWithinWindow(`${date}T00:00:00.000Z`, now, days) +} + +function sumDailySignal( + state: RegistryMonitorEntryState, + now: Date, + days: number, + successesKey: + | "availabilitySuccesses" + | "schemaSuccesses" + | "itemSuccesses" + | "dryRunSuccesses", + observationsKey: + | "availabilityObservations" + | "schemaObservations" + | "itemObservations" + | "dryRunObservations" +) { + return state.daily.reduce( + (signal, bucket) => { + if (!isBucketWithinWindow(bucket.date, now, days)) { + return signal + } + + signal.successes += bucket[successesKey] + signal.observations += bucket[observationsKey] + return signal + }, + { successes: 0, observations: 0 } + ) +} + +export function getRegistryHealthSignals( + state: RegistryMonitorEntryState, + registryUrl: string, + now: Date +): RegistryHealthSignals { + const availability7d = state.recentIndex.reduce( + (signal, observation) => { + if ( + observation.outcome === "bot_challenge" || + !isWithinWindow(observation.checkedAt, now, 7) + ) { + return signal + } + + signal.observations += 1 + signal.successes += observation.outcome === "reachable" ? 1 : 0 + return signal + }, + { successes: 0, observations: 0 } + ) + + return { + availability7d, + availability30d: sumDailySignal( + state, + now, + 30, + "availabilitySuccesses", + "availabilityObservations" + ), + indexSchema: sumDailySignal( + state, + now, + 30, + "schemaSuccesses", + "schemaObservations" + ), + itemValidity: sumDailySignal( + state, + now, + 30, + "itemSuccesses", + "itemObservations" + ), + dryRun: sumDailySignal( + state, + now, + 30, + "dryRunSuccesses", + "dryRunObservations" + ), + hygiene: { + https: registryUrl.startsWith("https://"), + contentTypeJson: state.latestHygiene.contentTypeJson, + noDuplicateNames: state.latestHygiene.noDuplicateNames, + nameMatches: state.latestHygiene.nameMatches, + }, + } +} + +function getObservedMean( + signals: RateSignal[], + minimumObservations: number, + fallback: number +) { + const eligible = signals.filter( + (signal) => signal.observations >= minimumObservations + ) + const observations = eligible.reduce( + (total, signal) => total + signal.observations, + 0 + ) + + if (observations === 0) { + return fallback + } + + const successes = eligible.reduce( + (total, signal) => total + signal.successes, + 0 + ) + return successes / observations +} + +export function calculateGlobalMeans( + state: RegistryMonitorState, + registryUrls: Record, + now: Date +): RegistryHealthGlobalMeans { + const signals = Object.entries(state.registries).map(([name, entry]) => + getRegistryHealthSignals(entry, registryUrls[name] ?? "http://invalid", now) + ) + + return { + availability7d: getObservedMean( + signals.map((signal) => signal.availability7d), + 24, + DEFAULT_GLOBAL_MEANS.availability7d + ), + availability30d: getObservedMean( + signals.map((signal) => signal.availability30d), + 24, + DEFAULT_GLOBAL_MEANS.availability30d + ), + indexSchema: getObservedMean( + signals.map((signal) => signal.indexSchema), + 12, + DEFAULT_GLOBAL_MEANS.indexSchema + ), + itemValidity: getObservedMean( + signals.map((signal) => signal.itemValidity), + 10, + DEFAULT_GLOBAL_MEANS.itemValidity + ), + dryRun: getObservedMean( + signals.map((signal) => signal.dryRun), + 3, + DEFAULT_GLOBAL_MEANS.dryRun + ), + } +} + +function smooth(signal: RateSignal, globalMean: number, priorWeight: number) { + return ( + (signal.successes + globalMean * priorWeight) / + (signal.observations + priorWeight) + ) +} + +function getHygienePoints(value: boolean | null) { + if (value === null) { + return 1.25 + } + + return value ? 2.5 : 0 +} + +function getNonChallengeObservations(state: RegistryMonitorEntryState) { + return state.recentIndex.filter( + (observation) => observation.outcome !== "bot_challenge" + ) +} + +function getUnavailableSince(state: RegistryMonitorEntryState) { + return state.availabilityOutageSince ?? state.firstObservedAt +} + +function deriveStatus( + state: RegistryMonitorEntryState, + signals: RegistryHealthSignals, + now: Date +) { + const observations = getNonChallengeObservations(state) + const latest = observations.at(-1) + const unavailableFor = + now.getTime() - new Date(getUnavailableSince(state)).getTime() + const unavailable = + unavailableFor >= DAY_MS && + (state.availabilityRecoveryRequired || latest?.outcome === "unreachable") + + if (unavailable) { + if (latest?.outcome === "reachable") { + return { + status: "unavailable" as const, + statusReason: { + code: "recovery_pending" as const, + message: "Waiting for a second successful recovery check", + }, + } + } + + return { + status: "unavailable" as const, + statusReason: { + code: "index_unavailable" as const, + message: "No successful index check in the last 24 hours", + }, + } + } + + const observationSpan = latest + ? new Date(latest.checkedAt).getTime() - + new Date(state.firstObservedAt).getTime() + : 0 + + if (observations.length < 24 || observationSpan < DAY_MS) { + return { + status: "observing" as const, + statusReason: { + code: "collecting_baseline" as const, + message: + observations.length < 24 + ? `Collecting baseline data (${observations.length} of 24 checks)` + : "Collecting a full day of baseline data", + }, + } + } + + const trailingFailures = state.consecutiveIndexFailures + const latestSchemaInvalid = + latest?.outcome === "reachable" && latest.schemaValid === false + const itemDegraded = + signals.itemValidity.observations >= 10 && + signals.itemValidity.successes / signals.itemValidity.observations < 0.9 + const recentDryRuns = state.recentDryRuns.slice(-2) + const dryRunDegraded = + recentDryRuns.length === 2 && + recentDryRuns.every((observation) => !observation.success) + + if (trailingFailures >= 3) { + return { + status: "degraded" as const, + statusReason: { + code: "consecutive_index_failures" as const, + message: `The registry index failed ${trailingFailures} consecutive checks`, + }, + } + } + + if (latestSchemaInvalid) { + return { + status: "degraded" as const, + statusReason: { + code: "index_schema_invalid" as const, + message: "The registry index failed schema validation", + }, + } + } + + if (itemDegraded) { + return { + status: "degraded" as const, + statusReason: { + code: "item_validation_failures" as const, + message: "Sampled registry items are failing validation", + }, + } + } + + if (dryRunDegraded) { + return { + status: "degraded" as const, + statusReason: { + code: "dry_run_failures" as const, + message: "The last two CLI dry-run checks failed", + }, + } + } + + if (state.availabilityRecoveryRequired) { + return { + status: "degraded" as const, + statusReason: { + code: "recovery_pending" as const, + message: "Waiting for a second successful recovery check", + }, + } + } + + return { + status: "healthy" as const, + statusReason: { + code: "healthy_thresholds" as const, + message: "Recent checks are within healthy thresholds", + }, + } +} + +export function calculateRegistryHealth({ + state, + registryUrl, + globalMeans, + now, +}: { + state: RegistryMonitorEntryState + registryUrl: string + globalMeans: RegistryHealthGlobalMeans + now: Date +}): RegistryHealth { + const signals = getRegistryHealthSignals(state, registryUrl, now) + const availability7d = smooth( + signals.availability7d, + globalMeans.availability7d, + PRIOR_WEIGHTS.availability7d + ) + const availability30d = smooth( + signals.availability30d, + globalMeans.availability30d, + PRIOR_WEIGHTS.availability30d + ) + const indexSchema = smooth( + signals.indexSchema, + globalMeans.indexSchema, + PRIOR_WEIGHTS.indexSchema + ) + const itemValidity = smooth( + signals.itemValidity, + globalMeans.itemValidity, + PRIOR_WEIGHTS.itemValidity + ) + const dryRun = smooth( + signals.dryRun, + globalMeans.dryRun, + PRIOR_WEIGHTS.dryRun + ) + + const breakdown = { + reliability: round( + 45 * (0.65 * availability7d + 0.35 * availability30d), + 3 + ), + correctness: round(10 * indexSchema + 15 * itemValidity, 3), + installability: round(20 * dryRun, 3), + hygiene: round( + (signals.hygiene.https ? 2.5 : 0) + + getHygienePoints(signals.hygiene.contentTypeJson) + + getHygienePoints(signals.hygiene.noDuplicateNames) + + getHygienePoints(signals.hygiene.nameMatches), + 3 + ), + } + const score = round( + breakdown.reliability + + breakdown.correctness + + breakdown.installability + + breakdown.hygiene, + 3 + ) + const { status, statusReason } = deriveStatus(state, signals, now) + const unavailableFor = + now.getTime() - new Date(getUnavailableSince(state)).getTime() + const hidden = status === "unavailable" && unavailableFor >= 7 * DAY_MS + const latestObservation = state.recentIndex.at(-1) + const checkedAt = latestObservation?.checkedAt ?? state.firstObservedAt + + return { + schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION, + scoreVersion: REGISTRY_HEALTH_SCORE_VERSION, + status, + statusReason, + score, + breakdown, + availability7d: round(availability7d, 6), + availability30d: round(availability30d, 6), + monitoringLimited: latestObservation?.outcome === "bot_challenge", + firstObservedAt: state.firstObservedAt, + checkedAt, + lastSuccessfulCheck: state.lastSuccessfulCheck, + hidden, + } +} + +export { DEFAULT_GLOBAL_MEANS, PRIOR_WEIGHTS } diff --git a/apps/v4/lib/registry-health/state.ts b/apps/v4/lib/registry-health/state.ts new file mode 100644 index 0000000000..3de0b82ebb --- /dev/null +++ b/apps/v4/lib/registry-health/state.ts @@ -0,0 +1,23 @@ +import type { RegistryMonitorEntryState } from "./schema" + +function createRegistryMonitorEntryState(now: Date) { + return { + firstObservedAt: now.toISOString(), + status: "observing", + consecutiveSuccessfulIndexes: 0, + consecutiveIndexFailures: 0, + availabilityRecoveryRequired: false, + itemCursor: 0, + itemNames: [], + recentIndex: [], + recentDryRuns: [], + daily: [], + latestHygiene: { + contentTypeJson: null, + noDuplicateNames: null, + nameMatches: null, + }, + } satisfies RegistryMonitorEntryState +} + +export { createRegistryMonitorEntryState } diff --git a/apps/v4/package.json b/apps/v4/package.json index a5b912e56c..34ff590a7a 100644 --- a/apps/v4/package.json +++ b/apps/v4/package.json @@ -18,6 +18,10 @@ "registry:build": "pnpm --filter=@shadcn/react build && pnpm --filter=@shadcn/helpers build && pnpm --filter=shadcn build && bun run ./scripts/build-registry.mts", "registry:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-registry.mts", "explore:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-explore.mts", + "registry:health": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/monitor-registries.mts", + "registry:health:prepare": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/monitor-registries.mts --phase prepare", + "registry:health:check": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/monitor-registries.mts --phase check", + "registry:health:publish": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/monitor-registries.mts --phase publish", "validate:registries": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/validate-registries.mts", "test:apps": "bun run ./scripts/build-test-app.mts", "postinstall": "fumadocs-mdx" @@ -110,6 +114,7 @@ "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@typescript-eslint/parser": "^8.31.0", + "@vercel/blob": "^2.8.0", "agentation": "^2.2.1", "baseline-browser-mapping": "^2.10.0", "eslint": "^9", @@ -120,8 +125,10 @@ "tw-animate-css": "^1.4.0", "typescript": "^5", "typescript-eslint": "^8.46.2", + "undici": "^7.27.2", "unist-builder": "3.0.0", - "unist-util-visit": "^4.1.2" + "unist-util-visit": "^4.1.2", + "vitest": "^3.2.6" }, "prettier": { "endOfLine": "lf", diff --git a/apps/v4/registry/README.md b/apps/v4/registry/README.md index aad5ee4901..fa09a23813 100644 --- a/apps/v4/registry/README.md +++ b/apps/v4/registry/README.md @@ -98,3 +98,94 @@ Notes: - `--style new-york-v4` is rejected because it is a legacy source registry, not a generated combination. Use `--registry new-york-v4` instead. - Unknown targets fail with the list of valid style ids. + +## Public registry health monitoring + +`directory.json` remains the authored source of truth for public registry +metadata. The scheduled monitor reads that file, checks each registry, and +publishes generated state to a private Vercel Blob store. Generated health data +must never be committed to the repository. + +The monitor writes these paths: + +```text +registry-health/v1/state.json +registry-health/v1/latest.json +registry-health/v1/runs/.json +registry-health/v1/daily/.json +``` + +`latest.json` is the only Blob document read by the website. The server reads it +with Blob credentials, then the public `/r/registries.json` route merges the +sanitized health overlay by exact namespace. The route fails open to the +original directory payload when health is disabled, stale, or unavailable. +If a newly added registry is not in the latest snapshot yet, it remains in the +payload without `health` until the monitor first observes it. +The route uses five-minute ISR, so normal traffic is served from cache and does +not read Blob on every request. + +Each newly generated health entry includes a stable status reason code and a +human-readable message for its primary status condition. The public route does +not expose raw monitor diagnostics, and status reasons do not affect scores or +ranking. + +### Authentication and configuration + +New Blob connections use Vercel OIDC inside the linked Vercel project. A +server-only `BLOB_READ_WRITE_TOKEN` is also supported when OIDC is unavailable. +The website must receive one of these credentials to read `latest.json`; client +code never receives them. + +The monitor runs in GitHub Actions, outside Vercel's OIDC runtime. Configure a +separate repository Actions secret named `BLOB_READ_WRITE_TOKEN` for that +workflow. The token is available only while downloading the previous private +state and publishing the new snapshot. The registry check step, contributor URL +requests, and CLI dry-run subprocesses run without Blob credentials. Never +expose the token through a `NEXT_PUBLIC_` variable, logs, or step summaries. + +Configure this Vercel server environment variable: + +- `REGISTRY_HEALTH_ENABLED`: set to `1` to merge the additive health object into + `/r/registries.json`; set to `0` to return the original four-field payload. + The Registry Directory does not consume the health object during the initial + data-collection phase. + +### Running and rollout + +The `Monitor Registries` workflow runs hourly and supports manual `hourly`, +`daily`, `weekly`, and `all` modes. Registry failures are recorded as data. The +workflow fails only when its own configuration, state, or publication fails. +Automatic daily and weekly work uses the last successful phase timestamps in +`state.json`, so a delayed cron run does not skip those checks. + +To run the monitor locally with credentials loaded from the linked Vercel +project, use the workspace-root Turbo command: + +```bash +MONITOR_MODE=hourly vercel env run -- pnpm registry:health +``` + +If the local environment file lives under `apps/v4`, run from that directory +and use `pnpm -w registry:health` so pnpm still selects the workspace-root +script. + +Turbo builds the monitor's workspace dependencies before running it. Local runs +run the same three phases as the workflow: authenticated state download, +credential-free registry checks, and authenticated publication. They publish +health state to the connected Blob store. Valid modes are `auto`, `hourly`, +`daily`, `weekly`, and `all`. + +For the initial rollout: + +1. Connect a private Blob store to the Vercel project. +2. Configure the GitHub Actions write secret. +3. Set `REGISTRY_HEALTH_ENABLED=1` and deploy the additive API overlay. +4. Dispatch an hourly run and confirm all four Blob path families. +5. Collect at least seven days of observations and inspect false positives. +6. Add the Registry Directory presentation in a follow-up after the data and + thresholds have been reviewed. + +To roll back the public API overlay immediately, set +`REGISTRY_HEALTH_ENABLED=0`. Disable the scheduled workflow separately if its +requests are causing load or false positives. Blob history can remain in place +for diagnosis. diff --git a/apps/v4/scripts/monitor-registries.mts b/apps/v4/scripts/monitor-registries.mts new file mode 100644 index 0000000000..b3e8642651 --- /dev/null +++ b/apps/v4/scripts/monitor-registries.mts @@ -0,0 +1,294 @@ +import { spawn } from "node:child_process" +import { promises as fs } from "node:fs" +import path from "node:path" + +import { registryDirectorySchema } from "../lib/registry-directory" +import { + cleanRegistryHealthHistory, + loadRegistryMonitorState, + publishRegistryHealth, +} from "../lib/registry-health/blob" +import { runLocalCliDryRun } from "../lib/registry-health/dry-run" +import { + runRegistryMonitor, + type RegistryMonitorMode, +} from "../lib/registry-health/monitor" +import { + registryMonitorOutputSchema, + registryMonitorStateSchema, +} from "../lib/registry-health/schema" +import directory from "../registry/directory.json" + +const PHASES = new Set(["prepare", "check", "publish"] as const) +const MODES = new Set([ + "auto", + "hourly", + "daily", + "weekly", + "all", +]) +const WORK_DIRECTORY = path.resolve(process.cwd(), ".registry-health") +const PREVIOUS_STATE_PATH = path.join(WORK_DIRECTORY, "previous-state.json") +const MONITOR_OUTPUT_PATH = path.join(WORK_DIRECTORY, "monitor-output.json") +const BLOB_ENVIRONMENT_VARIABLES = [ + "BLOB_READ_WRITE_TOKEN", + "VERCEL_OIDC_TOKEN", + "BLOB_STORE_ID", +] as const + +type MonitorPhase = "prepare" | "check" | "publish" + +function getArgument(name: string) { + const index = process.argv.indexOf(name) + if (index >= 0) { + const value = process.argv[index + 1] + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for ${name}`) + } + return value + } + + const argument = process.argv.find((value) => value.startsWith(`${name}=`)) + if (!argument) return undefined + + const value = argument.slice(name.length + 1) + if (!value) { + throw new Error(`Missing value for ${name}`) + } + return value +} + +function getPhase() { + const value = getArgument("--phase") + if (!value) return null + + if (!PHASES.has(value as MonitorPhase)) { + throw new Error(`Invalid monitor phase: ${value}`) + } + + return value as MonitorPhase +} + +function getMode() { + const value = getArgument("--mode") ?? process.env.MONITOR_MODE + const mode = (value ?? "auto") as RegistryMonitorMode + + if (!MODES.has(mode)) { + throw new Error(`Invalid monitor mode: ${value}`) + } + + return mode +} + +function getBlobToken() { + const token = process.env.BLOB_READ_WRITE_TOKEN + const hasVercelOidc = + process.env.VERCEL_OIDC_TOKEN && process.env.BLOB_STORE_ID + + if (!token && !hasVercelOidc) { + throw new Error( + "Blob authentication is missing. Configure BLOB_READ_WRITE_TOKEN outside Vercel or Vercel Blob OIDC inside a linked project." + ) + } + + return token +} + +function assertCredentialFreeCheck() { + const exposed = BLOB_ENVIRONMENT_VARIABLES.filter((name) => process.env[name]) + + if (exposed.length > 0) { + throw new Error( + `The check phase must not receive Blob credentials: ${exposed.join(", ")}` + ) + } +} + +async function readJson(pathname: string) { + return JSON.parse(await fs.readFile(pathname, "utf8")) as unknown +} + +async function writeJson(pathname: string, value: unknown) { + await fs.mkdir(path.dirname(pathname), { recursive: true }) + const temporaryPath = `${pathname}.${process.pid}.tmp` + await fs.writeFile(temporaryPath, JSON.stringify(value)) + await fs.rename(temporaryPath, pathname) +} + +async function writeStepSummary({ + latestPath, + mode, + totals, + diagnostics, + deleted, +}: { + latestPath: string + mode: RegistryMonitorMode + totals: { + registries: number + reachable: number + unavailable: number + challenges: number + itemChecks: number + dryRuns: number + } + diagnostics: string[] + deleted: number +}) { + if (!process.env.GITHUB_STEP_SUMMARY) return + + const rows = [ + "## Registry health monitor", + "", + `- Mode: \`${mode}\``, + `- Registries: ${totals.registries}`, + `- Reachable indexes: ${totals.reachable}`, + `- Unavailable indexes: ${totals.unavailable}`, + `- Bot challenges: ${totals.challenges}`, + `- Item checks: ${totals.itemChecks}`, + `- Dry runs: ${totals.dryRuns}`, + `- Expired history deleted: ${deleted}`, + `- Latest snapshot: ${latestPath}`, + ] + + if (diagnostics.length > 0) { + rows.push("", "### Diagnostics", "") + rows.push(...diagnostics.map((diagnostic) => `- ${diagnostic}`)) + } + + await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, `${rows.join("\n")}\n`) +} + +async function prepareMonitor() { + const token = getBlobToken() + const previousState = await loadRegistryMonitorState({ token }) + + await fs.rm(MONITOR_OUTPUT_PATH, { force: true }) + await writeJson(PREVIOUS_STATE_PATH, previousState) + console.log( + previousState + ? "Registry health state downloaded." + : "No previous registry health state found." + ) +} + +async function checkRegistries() { + assertCredentialFreeCheck() + + const previousStateValue = await readJson(PREVIOUS_STATE_PATH) + const previousState = + previousStateValue === null + ? null + : registryMonitorStateSchema.parse(previousStateValue) + const parsedDirectory = registryDirectorySchema.parse(directory) + const cliPath = path.resolve( + process.cwd(), + "../../packages/shadcn/dist/index.js" + ) + const result = await runRegistryMonitor({ + directory: parsedDirectory, + previousState, + mode: getMode(), + runDryRun: (options) => runLocalCliDryRun({ ...options, cliPath }), + }) + + await writeJson(MONITOR_OUTPUT_PATH, result) + console.log( + `Registry checks completed for ${result.run.totals.registries} registries.` + ) +} + +async function publishMonitor() { + const token = getBlobToken() + const result = registryMonitorOutputSchema.parse( + await readJson(MONITOR_OUTPUT_PATH) + ) + const publication = await publishRegistryHealth({ + ...result, + token, + }) + const cleanup = await cleanRegistryHealthHistory({ token }) + + await writeStepSummary({ + latestPath: publication.latestPath, + mode: result.run.mode, + totals: result.run.totals, + diagnostics: result.run.diagnostics, + deleted: cleanup.deleted, + }) + await fs.rm(WORK_DIRECTORY, { recursive: true, force: true }) + console.log(`Registry health snapshot published: ${publication.latestPath}`) +} + +async function runPackageScript( + script: string, + environment: NodeJS.ProcessEnv +) { + const isWindows = process.platform === "win32" + const command = isWindows ? "pnpm.cmd" : "pnpm" + + await new Promise((resolve, reject) => { + const child = spawn(command, ["run", script], { + cwd: process.cwd(), + env: environment, + shell: isWindows, + stdio: "inherit", + }) + + child.on("error", reject) + child.on("exit", (code, signal) => { + if (code === 0) { + resolve() + return + } + + reject( + new Error( + `${script} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}` + ) + ) + }) + }) +} + +async function runAllPhases() { + await runPackageScript("registry:health:prepare", process.env) + + const checkEnvironment = { ...process.env } + for (const name of BLOB_ENVIRONMENT_VARIABLES) { + delete checkEnvironment[name] + } + delete checkEnvironment.GITHUB_STEP_SUMMARY + + await runPackageScript("registry:health:check", checkEnvironment) + await runPackageScript("registry:health:publish", process.env) +} + +async function main() { + const phase = getPhase() + + if (!phase) { + await runAllPhases() + return + } + + if (phase === "prepare") { + await prepareMonitor() + return + } + + if (phase === "check") { + await checkRegistries() + return + } + + await publishMonitor() +} + +main().catch((error) => { + console.error( + "Registry health monitor failed:", + error instanceof Error ? error.message : "Unknown error" + ) + process.exitCode = 1 +}) diff --git a/apps/v4/scripts/validate-registries.mts b/apps/v4/scripts/validate-registries.mts index 8d0679c0f2..6fa9d3f4a7 100644 --- a/apps/v4/scripts/validate-registries.mts +++ b/apps/v4/scripts/validate-registries.mts @@ -1,32 +1,11 @@ import { promises as fs } from "fs" import path from "path" -import { z } from "zod" -const registryEntrySchema = z.object({ - name: z.string().regex(/^@[a-zA-Z0-9][a-zA-Z0-9-_]*$/), - homepage: z.string().url(), - url: z.string().refine((url) => url.includes("{name}"), { - message: "URL must include {name} placeholder", - }), - description: z.string(), -}) - -const registriesSchema = z.array(registryEntrySchema) - -const directoryEntrySchema = registryEntrySchema.extend({ - logo: z.string(), -}) - -const directorySchema = z.array(directoryEntrySchema) - -function getRegistries(directory: z.infer) { - return directory.map(({ name, homepage, url, description }) => ({ - name, - homepage, - url, - description, - })) -} +import { + createPublicRegistryDirectory, + publicRegistryDirectorySchema, + registryDirectorySchema, +} from "../lib/registry-directory" async function main() { let hasErrors = false @@ -36,7 +15,7 @@ async function main() { const directoryContent = await fs.readFile(directoryFile, "utf-8") const directoryData = JSON.parse(directoryContent) - const directoryResult = directorySchema.safeParse(directoryData) + const directoryResult = registryDirectorySchema.safeParse(directoryData) if (!directoryResult.success) { console.error("❌ directory.json validation failed:") console.error(directoryResult.error.format()) @@ -47,8 +26,8 @@ async function main() { // 2. Validate the public registries payload served by /r/registries.json. if (directoryResult.success) { - const registriesResult = registriesSchema.safeParse( - getRegistries(directoryResult.data) + const registriesResult = publicRegistryDirectorySchema.safeParse( + createPublicRegistryDirectory(directoryResult.data) ) if (!registriesResult.success) { diff --git a/package.json b/package.json index fb8e5ddf4f..5521f6a1e9 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "build:packages": "turbo run build --filter=./packages/*", "registry:build": "pnpm --filter=v4 registry:build && pnpm lint:fix && pnpm format:write -- --loglevel silent", "registry:capture": "pnpm --filter=v4 registry:capture", + "registry:health": "turbo run registry:health --filter=v4", + "registry:health:prepare": "turbo run registry:health:prepare --filter=v4", + "registry:health:check": "turbo run registry:health:check --filter=v4", + "registry:health:publish": "turbo run registry:health:publish --filter=v4", "explore:capture": "pnpm --filter=v4 explore:capture", "dev": "turbo run dev", "shadcn:dev": "turbo run dev --filter=shadcn", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca0d12ffdb..c5e4e847d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -379,6 +379,9 @@ importers: '@typescript-eslint/parser': specifier: ^8.31.0 version: 8.39.0(eslint@9.26.0(hono@4.12.23)(jiti@2.7.0))(typescript@5.9.2) + '@vercel/blob': + specifier: ^2.8.0 + version: 2.8.0 agentation: specifier: ^2.2.1 version: 2.2.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -409,12 +412,18 @@ importers: typescript-eslint: specifier: ^8.46.2 version: 8.46.2(eslint@9.26.0(hono@4.12.23)(jiti@2.7.0))(typescript@5.9.2) + undici: + specifier: ^7.27.2 + version: 7.27.2 unist-builder: specifier: 3.0.0 version: 3.0.0 unist-util-visit: specifier: ^4.1.2 version: 4.1.2 + vitest: + specifier: ^3.2.6 + version: 3.2.6(@types/debug@4.1.12)(@types/node@20.19.10)(@vitest/browser@3.2.6)(jiti@2.7.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.10)(typescript@5.9.2))(tsx@4.20.3)(yaml@2.8.1) packages/helpers: devDependencies: @@ -4617,10 +4626,25 @@ packages: vue-router: optional: true + '@vercel/blob@2.8.0': + resolution: {integrity: sha512-Nu+HWKpkgovCh/ezlG7wCVwF7RErTzLzZMbGKFBdGBCbTKyK+s5VXPLl+0+TpNEQPH8AVaGzOpIsXUOtkqylCQ==} + engines: {node: '>=20.0.0'} + + '@vercel/cli-config@0.2.4': + resolution: {integrity: sha512-kZ5SojbrV06GHoU6QIWGwDXLov+s9rWZ7QqdqKfJfBGCNUieGfgaCjeeenNy8Y+QC0bwC0dZ2B4l5Hvdmrgpdw==} + + '@vercel/cli-exec@1.0.1': + resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==} + engines: {node: '>= 18'} + '@vercel/oidc@3.2.0': resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vercel/oidc@3.8.5': + resolution: {integrity: sha512-RwXYtnt6za+5UO4IaLywN/6B95AlLqynPRUWRJxeJ/qufwkcLUbZNUxYtzT0uMpuraWhlNcGqPNGkTnZr4BGBw==} + engines: {node: '>= 20'} + '@vitest/browser@3.2.6': resolution: {integrity: sha512-CNjSynGBtAVOMTfQITv6Bc8da4/XTU1izorocbDStjUsynXcgx2FHVssh+10a8bKd/BxoqDdQtuSbYHfk302Wg==} peerDependencies: @@ -4846,6 +4870,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -6728,6 +6755,10 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + is-bun-module@2.0.0: resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} @@ -6943,6 +6974,9 @@ packages: joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -7815,6 +7849,10 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -8565,6 +8603,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -9257,6 +9299,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + undici@7.27.2: resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} engines: {node: '>=20.18.1'} @@ -9671,6 +9717,14 @@ packages: resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==} engines: {node: '>=20'} + xdg-app-paths@5.5.1: + resolution: {integrity: sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==} + engines: {node: '>= 6.0'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -9742,6 +9796,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -9831,7 +9888,7 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.3.0 - tinyexec: 1.0.2 + tinyexec: 1.2.4 '@antfu/ni@25.0.0': dependencies: @@ -13597,8 +13654,32 @@ snapshots: next: 16.3.0-canary.97(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@types/node@20.19.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 + '@vercel/blob@2.8.0': + dependencies: + '@vercel/oidc': 3.8.5 + async-retry: 1.3.3 + is-buffer: 2.0.5 + is-node-process: 1.2.0 + throttleit: 2.1.0 + undici: 6.28.0 + + '@vercel/cli-config@0.2.4': + dependencies: + xdg-app-paths: 5.5.1 + zod: 4.1.11 + + '@vercel/cli-exec@1.0.1': + dependencies: + execa: 5.1.1 + '@vercel/oidc@3.2.0': {} + '@vercel/oidc@3.8.5': + dependencies: + '@vercel/cli-config': 0.2.4 + '@vercel/cli-exec': 1.0.1 + jose: 5.10.0 + '@vitest/browser@3.2.6(msw@2.10.4(@types/node@20.19.10)(typescript@5.9.2))(playwright@1.61.0)(vite@7.3.2(@types/node@20.19.10)(jiti@1.21.7)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@3.2.6)': dependencies: '@testing-library/dom': 10.4.1 @@ -13886,6 +13967,10 @@ snapshots: async-function@1.0.0: {} + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + asynckit@0.4.0: {} autoprefixer@10.4.21(postcss@8.5.15): @@ -16253,6 +16338,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-buffer@2.0.5: {} + is-bun-module@2.0.0: dependencies: semver: 7.7.3 @@ -16436,6 +16523,8 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + jose@5.10.0: {} + jose@6.1.3: {} jotai@2.15.0(@babel/core@7.28.0)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.3): @@ -17496,6 +17585,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.0 + os-paths@4.4.0: {} + outdent@0.5.0: {} outvariant@1.4.3: {} @@ -18381,6 +18472,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + retry@0.13.1: {} + reusify@1.1.0: {} rimraf@6.0.1: @@ -19295,6 +19388,8 @@ snapshots: undici-types@6.21.0: {} + undici@6.28.0: {} + undici@7.27.2: {} unicorn-magic@0.1.0: {} @@ -19848,6 +19943,15 @@ snapshots: is-wsl: 3.1.0 powershell-utils: 0.1.0 + xdg-app-paths@5.5.1: + dependencies: + os-paths: 4.4.0 + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} @@ -19902,6 +20006,8 @@ snapshots: zod@3.25.76: {} + zod@4.1.11: {} + zod@4.3.6: {} zod@4.4.3: {} diff --git a/turbo.json b/turbo.json index d834e0f4c6..89ceef98ac 100644 --- a/turbo.json +++ b/turbo.json @@ -61,6 +61,45 @@ }, "registry:build": { "outputs": [] + }, + "registry:health": { + "dependsOn": ["^build"], + "cache": false, + "env": ["MONITOR_MODE"], + "passThroughEnv": [ + "BLOB_READ_WRITE_TOKEN", + "VERCEL_OIDC_TOKEN", + "BLOB_STORE_ID", + "GITHUB_STEP_SUMMARY" + ], + "outputs": [] + }, + "registry:health:prepare": { + "dependsOn": ["^build"], + "cache": false, + "passThroughEnv": [ + "BLOB_READ_WRITE_TOKEN", + "VERCEL_OIDC_TOKEN", + "BLOB_STORE_ID" + ], + "outputs": [] + }, + "registry:health:check": { + "dependsOn": ["^build"], + "cache": false, + "env": ["MONITOR_MODE"], + "outputs": [] + }, + "registry:health:publish": { + "dependsOn": ["^build"], + "cache": false, + "passThroughEnv": [ + "BLOB_READ_WRITE_TOKEN", + "VERCEL_OIDC_TOKEN", + "BLOB_STORE_ID", + "GITHUB_STEP_SUMMARY" + ], + "outputs": [] } } }