diff --git a/apps/cli/src/commands/login.ts b/apps/cli/src/commands/login.ts index fa293613..f7871815 100644 --- a/apps/cli/src/commands/login.ts +++ b/apps/cli/src/commands/login.ts @@ -5,6 +5,16 @@ import { copyToClipboard } from "../lib/clipboard"; import { loadConfig, saveConfig } from "../lib/config"; import { RegistryClient } from "../lib/registry"; +/** True for an http(s) URL, so we never hand a non-web scheme to the OS browser opener. */ +function isWebUrl(value: string): boolean { + try { + const { protocol } = new URL(value); + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +} + export const login = defineCommand({ name: "login", description: "Authorize this machine (GitHub device flow)", @@ -28,7 +38,10 @@ export const login = defineCommand({ // code in the URL so the page can pre-fill it; the note above is the manual // fallback when the browser cannot be opened (CI, SSH, `--no-browser`). const approvalUrl = device.verification_uri_complete ?? device.verification_uri; - const opened = values.noBrowser ? false : await openBrowser(approvalUrl); + // Only hand an http(s) URL to the OS opener; a hostile/MITM'd registry could otherwise point it + // at a local file or app scheme. Non-web URLs fall through to the printed-code path below. + const opened = + values.noBrowser || !isWebUrl(approvalUrl) ? false : await openBrowser(approvalUrl); if (opened) { p.log.info("Opened your browser to finish login."); } else { diff --git a/apps/cli/src/commands/prepare.ts b/apps/cli/src/commands/prepare.ts index c3a6b5b9..f0cca90e 100644 --- a/apps/cli/src/commands/prepare.ts +++ b/apps/cli/src/commands/prepare.ts @@ -1,17 +1,27 @@ import { type Packed, packDirectory } from "../lib/tarball"; import { printSummary } from "./summary"; -import { assertLocalesValid, assertPublishable } from "./validate"; +import { + assertCapabilitiesBacked, + assertCompiles, + assertFilesPresent, + assertLocalesValid, + assertPublishable, +} from "./validate"; /** - * Pack a plugin directory, validate it against the publish contract, and print - * the tarball summary. Shared by `pack` and `publish`; throws a CliError when - * the manifest is not a publishable Brika plugin or a bundled locale file does - * not match the store-metadata schema. + * Pack a plugin directory, validate it against the publish contract, confirm every declared + * capability is backed by code and that it compiles, and print the tarball summary. Shared by `pack` + * and `publish`; throws a CliError when the manifest is not a publishable Brika plugin, references + * files missing from the tarball, declares a capability it never ships, has invalid localized store + * metadata, or does not compile (a fake/broken plugin caught before upload). */ export async function prepare(dir: string): Promise { const packed = await packDirectory(dir); assertPublishable(packed); + assertFilesPresent(packed); + assertCapabilitiesBacked(packed); assertLocalesValid(packed); + await assertCompiles(dir, packed); printSummary(packed); return packed; } diff --git a/apps/cli/src/commands/validate.test.ts b/apps/cli/src/commands/validate.test.ts new file mode 100644 index 00000000..397d28ca --- /dev/null +++ b/apps/cli/src/commands/validate.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { packDirectory } from "../lib/tarball"; +import { assertCompiles } from "./validate"; + +let dir: string; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "brika-compile-test-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +async function pkg(manifest: Record, main: string) { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ name: "@acme/x", version: "1.0.0", main: "./index.ts", ...manifest }), + ); + await writeFile(join(dir, "index.ts"), main); +} + +describe("assertCompiles", () => { + test("passes for a plugin whose entrypoint compiles", async () => { + await pkg({}, "export default function plugin() {\n return 1;\n}\n"); + await expect(assertCompiles(dir, await packDirectory(dir))).resolves.toBeUndefined(); + }); + + test("throws for a plugin with a syntax error (a fake/broken plugin)", async () => { + await pkg({}, "export default function plugin( {\n"); // unbalanced paren + await expect(assertCompiles(dir, await packDirectory(dir))).rejects.toThrow( + /does not compile|failed to compile/, + ); + }); + + test("treats declared dependencies as external (uninstalled deps don't fail the build)", async () => { + await pkg( + { dependencies: { "some-uninstalled-dep": "1.0.0" } }, + 'import dep from "some-uninstalled-dep";\nexport default dep;\n', + ); + await expect(assertCompiles(dir, await packDirectory(dir))).resolves.toBeUndefined(); + }); +}); diff --git a/apps/cli/src/commands/validate.ts b/apps/cli/src/commands/validate.ts index b71e2648..c2ea8296 100644 --- a/apps/cli/src/commands/validate.ts +++ b/apps/cli/src/commands/validate.ts @@ -1,5 +1,11 @@ +import { join } from "node:path"; import { CliError } from "@brika/cli-kit"; -import { RegistryPublishSchema, validateStoreLocales } from "@brika/schema/store"; +import { + RegistryPublishSchema, + validateManifestCapabilities, + validateManifestFiles, + validateStoreLocales, +} from "@brika/schema/store"; import type { Packed } from "../lib/tarball"; /** Reject anything that is not a valid, listable Brika plugin before uploading. */ @@ -15,6 +21,63 @@ export function assertPublishable(packed: Packed): void { throw new CliError(`${packed.name} is not a publishable Brika plugin:\n${detail}`); } +/** Reject a manifest that references files (main, icon, screenshots) missing from the packed tarball. */ +export function assertFilesPresent(packed: Packed): void { + const result = RegistryPublishSchema.safeParse(packed.manifest); + if (!result.success) return; // assertPublishable already reported the schema failure + const issues = validateManifestFiles(result.data, packed.files); + if (issues.length === 0) return; + const detail = issues.map((issue) => ` - ${issue}`).join("\n"); + throw new CliError(`${packed.name} references files missing from the tarball:\n${detail}`); +} + +/** Reject a manifest that declares a capability (brick/block/spark/page/tool) with no backing source file. */ +export function assertCapabilitiesBacked(packed: Packed): void { + const result = RegistryPublishSchema.safeParse(packed.manifest); + if (!result.success) return; // assertPublishable already reported the schema failure + const issues = validateManifestCapabilities(result.data, packed.files); + if (issues.length === 0) return; + const detail = issues.map((issue) => ` - ${issue}`).join("\n"); + throw new CliError(`${packed.name} declares capabilities with no backing code:\n${detail}`); +} + +/** Declared dependency names (deps + peer + dev), treated as external during the compile check. */ +function declaredDeps(manifest: Record): string[] { + const names = new Set(); + for (const key of ["dependencies", "peerDependencies", "devDependencies"]) { + const record = manifest[key]; + if (record !== null && typeof record === "object") { + for (const name of Object.keys(record)) names.add(name); + } + } + return [...names]; +} + +/** + * Compile the plugin's entrypoint with `Bun.build` (the same bundler the Brika hub compiles plugins + * with) to prove it is a real, buildable plugin rather than a stub or a broken source tree. Declared + * dependencies are marked external, so this checks the plugin's own code, not its installed deps. + * Throws a CliError on any build error. + */ +export async function assertCompiles(dir: string, packed: Packed): Promise { + const main = packed.manifest.main; + if (typeof main !== "string") return; // assertPublishable already requires a `main` + let result: Awaited>; + try { + result = await Bun.build({ + entrypoints: [join(dir, main)], + target: "browser", + external: declaredDeps(packed.manifest), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new CliError(`${packed.name} failed to compile (from ${main}):\n - ${message}`); + } + if (result.success) return; + const detail = result.logs.map((log) => ` - ${String(log)}`).join("\n"); + throw new CliError(`${packed.name} does not compile (from ${main}):\n${detail}`); +} + /** Reject bundled `locales//store.json` files that don't match the schema. */ export function assertLocalesValid(packed: Packed): void { const screenshots = packed.manifest.screenshots; diff --git a/apps/registry/bench/compile.bench.ts b/apps/registry/bench/compile.bench.ts new file mode 100644 index 00000000..118b3620 --- /dev/null +++ b/apps/registry/bench/compile.bench.ts @@ -0,0 +1,117 @@ +/** + * Compile-speed benchmark: our TS compile (sucrase, the Worker publish gate) vs Bun native. + * + * NOT an equivalence check - the three paths do DIFFERENT jobs and emit DIFFERENT output: + * - sucrase.transform per-file transpile, N files -> N JS chunks. Classic JSX. Worker-safe. + * - Bun.Transpiler per-file transpile, N files -> N JS chunks. Automatic JSX runtime. + * - Bun.build resolve graph + tree-shake + BUNDLE, N files -> 1 bundle. Can process CSS. + * + * Only sucrase vs Bun.Transpiler is a fair head-to-head (same per-file transpile job). Bun.build is + * reported as a whole-corpus total, not per-file: it's what the CLI's `assertCompiles` actually runs, + * but it's a bundle, not a transpile, so per-file numbers would mislead. + * + * Run: `bun apps/registry/bench/compile.bench.ts [fileCount] [iterations]` + */ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { transform } from "sucrase"; + +const FILE_COUNT = Number(process.argv[2] ?? 40); +const ITERATIONS = Number(process.argv[3] ?? 50); + +// A realistic-ish plugin file: types, a React-ish component (JSX), an external import, some logic. +function makeSource(i: number): string { + return `import { helper } from "some-dep"; +import { local${(i + 1) % FILE_COUNT} } from "./mod${(i + 1) % FILE_COUNT}"; + +export interface Props${i} { id: string; count: number; tags: readonly string[]; } + +export const local${i} = (p: Props${i}): number => + p.count + p.tags.length + helper(p.id).length + local${(i + 1) % FILE_COUNT}; + +export const View${i} = (p: Props${i}) => ( +
+

{p.id}

+ {local${i}(p)} + {p.tags.map((t) => {t})} +
+); +`; +} + +const dir = mkdtempSync(join(tmpdir(), "brika-bench-")); +const enc = new TextEncoder(); +const entries: { path: string; data: Uint8Array; src: string }[] = []; +for (let i = 0; i < FILE_COUNT; i++) { + const src = makeSource(i); + const path = `mod${i}.tsx`; + writeFileSync(join(dir, path), src); + entries.push({ path, data: enc.encode(src), src }); +} +const entrypoint = join(dir, "mod0.tsx"); // graph is a ring, so this reaches every file + +// --- contenders (each transpiles/builds the whole corpus once) --------------- + +function runSucrase(): void { + const decoder = new TextDecoder(); + for (const e of entries) { + transform(decoder.decode(e.data), { transforms: ["typescript", "jsx"] }); + } +} + +const transpiler = new Bun.Transpiler({ loader: "tsx" }); +function runBunTranspiler(): void { + for (const e of entries) transpiler.transformSync(e.src); +} + +// external = the plugin's declared deps (as `assertCompiles` does). Relative `./modN` imports stay +// internal so the whole ring is bundled; `["*"]` would externalize those too and build only mod0. +const BUILD_EXTERNAL = ["some-dep", "react", "react/jsx-runtime", "react/jsx-dev-runtime"]; +async function runBunBuild(): Promise { + const r = await Bun.build({ + entrypoints: [entrypoint], + target: "browser", + external: BUILD_EXTERNAL, + }); + if (!r.success) throw new Error(`bench build failed: ${r.logs.map(String).join(", ")}`); +} + +// --- timing harness ---------------------------------------------------------- + +function median(xs: number[]): number { + const s = [...xs].sort((a, b) => a - b); + const m = s.length >> 1; + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +} + +async function bench( + name: string, + fn: () => void | Promise, + perFile: boolean, +): Promise { + for (let i = 0; i < 5; i++) await fn(); // warmup + const times: number[] = []; + for (let i = 0; i < ITERATIONS; i++) { + const t0 = Bun.nanoseconds(); + await fn(); + times.push((Bun.nanoseconds() - t0) / 1e6); // ms + } + const med = median(times); + // per-file is meaningful only for the per-file transpilers; Bun.build is a whole-corpus bundle. + const rate = perFile + ? `${((med / FILE_COUNT) * 1000).toFixed(1).padStart(7)} us/file` + : " (bundle)"; + console.log(` ${name.padEnd(16)} ${med.toFixed(3).padStart(9)} ms ${rate}`); +} + +console.log(`\ncompile bench - ${FILE_COUNT} .tsx files, median of ${ITERATIONS} runs`); +console.log(" (per-file: transpile job | (bundle): whole-corpus, different job)\n"); +try { + await bench("sucrase", runSucrase, true); + await bench("Bun.Transpiler", runBunTranspiler, true); + await bench("Bun.build", runBunBuild, false); +} finally { + rmSync(dir, { recursive: true, force: true }); +} +console.log(""); diff --git a/apps/registry/package.json b/apps/registry/package.json index ef497baa..df108fb7 100644 --- a/apps/registry/package.json +++ b/apps/registry/package.json @@ -22,6 +22,7 @@ "cf-typegen": "wrangler types" }, "dependencies": { + "@brika/compiler": "0.4.0-00e5f35c9ec7", "@brika/di": "workspace:*", "@brika/env": "workspace:*", "@brika/registry-core": "workspace:*", @@ -32,6 +33,7 @@ "@brika/tx": "workspace:*", "drizzle-orm": "^0.45.2", "hono": "^4.12.25", + "sucrase": "^3.35.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/apps/registry/src/adapters/compile-check.test.ts b/apps/registry/src/adapters/compile-check.test.ts new file mode 100644 index 00000000..3e4ec9a0 --- /dev/null +++ b/apps/registry/src/adapters/compile-check.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { checkPluginCompiles } from "./compile-check"; + +const enc = new TextEncoder(); +const file = (path: string, text: string) => ({ path, data: enc.encode(text) }); + +describe("checkPluginCompiles", () => { + test("accepts valid TS + TSX sources", () => { + expect( + checkPluginCompiles([ + file("src/index.ts", "const n: number = 1;\nexport default n;\n"), + file("src/view.tsx", 'export const V = () =>
hi
;\n'), + ]), + ).toEqual({ ok: true }); + }); + + test("rejects a source file with a syntax error, naming it", () => { + const result = checkPluginCompiles([file("src/index.ts", "export default function( {\n")]); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("src/index.ts"); + }); + + test("ignores non-source files (json, svg, css, .d.ts)", () => { + expect( + checkPluginCompiles([ + file("package.json", "{not json but not transpiled}"), + file("icon.svg", ""), + file("styles.css", ".a { color: red }"), + file("types.d.ts", "export type X = number"), + ]), + ).toEqual({ ok: true }); + }); + + test("does not treat a TS type-cast as JSX in a .ts file", () => { + // `x` is a cast in .ts; the JSX transform must not be applied to .ts. + expect( + checkPluginCompiles([file("src/a.ts", "export const s = (1 as unknown);\n")]), + ).toEqual({ ok: true }); + }); +}); diff --git a/apps/registry/src/adapters/compile-check.ts b/apps/registry/src/adapters/compile-check.ts new file mode 100644 index 00000000..3e5a222a --- /dev/null +++ b/apps/registry/src/adapters/compile-check.ts @@ -0,0 +1,48 @@ +import { transform } from "sucrase"; + +/** + * Server-side plugin compile gate. The Brika hub compiles plugins with `Bun.build`, which cannot run + * in a Cloudflare Worker, so at publish time we transpile every shipped source file with sucrase (a + * pure-JS TS/TSX transpiler that does run in the Worker). A fake or broken plugin, garbage bytes, a + * truncated build, invalid TSX, fails to transpile and the publish is rejected. This is a + * syntax/parse gate, not a type check or a full bundle: it proves the source is real, compilable + * plugin code rather than a stub uploaded to squat a name. The CLI still runs the faithful full + * `Bun.build` before upload; this is the enforcement that also covers publishers who bypass the CLI. + */ + +/** Executable source extensions we transpile. `.d.ts` is declarations only, so it is skipped. */ +const SOURCE = /\.(?:tsx|ts|jsx|js|mts|cts|mjs|cjs)$/; + +function isSource(path: string): boolean { + return SOURCE.test(path) && !path.endsWith(".d.ts"); +} + +/** `.tsx`/`.jsx` need the JSX transform; a `.ts` must NOT (there `x` is a cast, not an element). */ +function transformsFor(path: string): ["typescript", "jsx"] | ["typescript"] { + return path.endsWith("x") ? ["typescript", "jsx"] : ["typescript"]; +} + +export interface SourceEntry { + readonly path: string; + readonly data: Uint8Array; +} + +/** + * Transpile every source file in the tarball. Returns the first file that fails to compile, or `ok` + * when they all transpile. Pure and Worker-safe (sucrase is JS-only). + */ +export function checkPluginCompiles( + entries: readonly SourceEntry[], +): { ok: true } | { ok: false; message: string } { + const decoder = new TextDecoder(); + for (const entry of entries) { + if (!isSource(entry.path)) continue; + try { + transform(decoder.decode(entry.data), { transforms: transformsFor(entry.path) }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, message: `${entry.path} does not compile: ${detail}` }; + } + } + return { ok: true }; +} diff --git a/apps/registry/src/adapters/manifest-validator.test.ts b/apps/registry/src/adapters/manifest-validator.test.ts index b095563f..a635af5d 100644 --- a/apps/registry/src/adapters/manifest-validator.test.ts +++ b/apps/registry/src/adapters/manifest-validator.test.ts @@ -52,14 +52,19 @@ function gzipTar(entries: ReadonlyArray<{ path: string; text: string }>): Uint8A return new Uint8Array(gzipSync(tar)); } -const EMPTY = gzipTar([]); +// The files `valid` points at; a tarball must bundle them to clear the referenced-file gate. +const BASE_FILES: ReadonlyArray<{ path: string; text: string }> = [ + { path: "src/index.ts", text: "export default {}" }, + { path: "icon.svg", text: "" }, +]; +const BASE = gzipTar(BASE_FILES); test("accepts a complete plugin manifest", async () => { - expect(await validator.validate(valid, EMPTY)).toEqual({ ok: true }); + expect(await validator.validate(valid, BASE)).toEqual({ ok: true, actions: [] }); }); test("requires an icon", async () => { - const result = await validator.validate({ ...valid, icon: "" }, EMPTY); + const result = await validator.validate({ ...valid, icon: "" }, BASE); expect(result.ok).toBe(false); if (!result.ok) expect(result.message).toContain("icon"); }); @@ -67,7 +72,7 @@ test("requires an icon", async () => { test("requires a title (displayName)", async () => { const { displayName, ...withoutTitle } = valid; void displayName; - const result = await validator.validate(withoutTitle, EMPTY); + const result = await validator.validate(withoutTitle, BASE); expect(result.ok).toBe(false); if (!result.ok) expect(result.message).toContain("displayName"); }); @@ -75,47 +80,127 @@ test("requires a title (displayName)", async () => { test("requires a description", async () => { const { description, ...withoutDescription } = valid; void description; - expect((await validator.validate(withoutDescription, EMPTY)).ok).toBe(false); + expect((await validator.validate(withoutDescription, BASE)).ok).toBe(false); }); test("requires the brika engine", async () => { const { engines, ...withoutEngine } = valid; void engines; - expect((await validator.validate(withoutEngine, EMPTY)).ok).toBe(false); + expect((await validator.validate(withoutEngine, BASE)).ok).toBe(false); }); test("requires a main entrypoint", async () => { const { main, ...withoutMain } = valid; void main; - const result = await validator.validate(withoutMain, EMPTY); + const result = await validator.validate(withoutMain, BASE); expect(result.ok).toBe(false); if (!result.ok) expect(result.message).toContain("main"); }); test("rejects a non-semver version", async () => { - const result = await validator.validate({ ...valid, version: "latest" }, EMPTY); + const result = await validator.validate({ ...valid, version: "latest" }, BASE); expect(result.ok).toBe(false); if (!result.ok) expect(result.message).toContain("version"); }); test("rejects an icon path that escapes the package root", async () => { - const result = await validator.validate({ ...valid, icon: "../../etc/passwd" }, EMPTY); + const result = await validator.validate({ ...valid, icon: "../../etc/passwd" }, BASE); expect(result.ok).toBe(false); if (!result.ok) expect(result.message).toContain("icon"); }); +test("rejects a manifest whose main entrypoint is missing from the tarball", async () => { + const tarball = gzipTar([{ path: "icon.svg", text: "" }]); + const result = await validator.validate(valid, tarball); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("main"); +}); + +test("rejects a manifest whose icon is missing from the tarball", async () => { + const tarball = gzipTar([{ path: "src/index.ts", text: "export default {}" }]); + const result = await validator.validate(valid, tarball); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("icon"); +}); + +test("rejects an empty main entrypoint", async () => { + const tarball = gzipTar([ + { path: "src/index.ts", text: "" }, + { path: "icon.svg", text: "" }, + ]); + const result = await validator.validate(valid, tarball); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("empty"); +}); + +test("accepts a plugin whose declared brick is backed by a source file", async () => { + const tarball = gzipTar([ + ...BASE_FILES, + { path: "src/bricks/current.tsx", text: "export default {}" }, + ]); + expect(await validator.validate({ ...valid, bricks: [{ id: "current" }] }, tarball)).toEqual({ + ok: true, + actions: [], + }); +}); + +test("rejects a manifest that declares a brick with no backing code (a fake plugin)", async () => { + const result = await validator.validate({ ...valid, bricks: [{ id: "ghost" }] }, BASE); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("ghost"); +}); + +test("returns the server actions the compile gate discovered", async () => { + const tarball = gzipTar([ + ...BASE_FILES, + { + path: "src/actions.ts", + text: "import { defineAction } from '@brika/sdk/actions';\nexport const listDevices = defineAction(async () => {});", + }, + ]); + const result = await validator.validate(valid, tarball); + expect(result.ok).toBe(true); + // id matches the runtime's computeActionId(sha256("src/actions.ts\0listDevices")) + if (result.ok) { + expect(result.actions).toEqual([ + { file: "src/actions.ts", name: "listDevices", actionId: "0844025241d0" }, + ]); + } +}); + +test("rejects a plugin whose source does not compile", async () => { + const tarball = gzipTar([ + { path: "src/index.ts", text: "export default function( {" }, // syntax error + { path: "icon.svg", text: "" }, + ]); + const result = await validator.validate(valid, tarball); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("does not compile"); +}); + +test("rejects a server main whose import is unresolved (bundle gate, not just per-file syntax)", async () => { + // Valid syntax, so the per-file sweep passes; only bundling the graph catches the missing module. + const tarball = gzipTar([ + { path: "src/index.ts", text: "import './does-not-exist';\nexport default {};" }, + { path: "icon.svg", text: "" }, + ]); + expect((await validator.validate(valid, tarball)).ok).toBe(false); +}); + test("accepts a valid bundled locale file", async () => { const tarball = gzipTar([ + ...BASE_FILES, { path: "locales/fr/store.json", text: JSON.stringify({ title: "Plugin X", description: "Fait une chose" }), }, ]); - expect(await validator.validate(valid, tarball)).toEqual({ ok: true }); + expect(await validator.validate(valid, tarball)).toEqual({ ok: true, actions: [] }); }); test("rejects an invalid bundled locale file, naming the path", async () => { const tarball = gzipTar([ + ...BASE_FILES, { path: "locales/fr/store.json", text: JSON.stringify({ description: "missing title" }) }, ]); const result = await validator.validate(valid, tarball); @@ -139,9 +224,10 @@ test("rejects a bundled file over the per-file size limit", async () => { test("accepts a tarball whose package.json matches the published manifest", async () => { const tarball = gzipTar([ + ...BASE_FILES, { path: "package.json", text: JSON.stringify({ name: valid.name, version: valid.version }) }, ]); - expect(await validator.validate(valid, tarball)).toEqual({ ok: true }); + expect(await validator.validate(valid, tarball)).toEqual({ ok: true, actions: [] }); }); test("rejects a tarball whose package.json name/version diverges from the manifest", async () => { diff --git a/apps/registry/src/adapters/manifest-validator.ts b/apps/registry/src/adapters/manifest-validator.ts index 248d0f49..54675b5b 100644 --- a/apps/registry/src/adapters/manifest-validator.ts +++ b/apps/registry/src/adapters/manifest-validator.ts @@ -1,8 +1,25 @@ -import { type ManifestValidator, REGISTRY_LIMITS, readTarGzEntries } from "@brika/registry-core"; -import { RegistryPublishSchema, storeLocaleOf, validateStoreLocales } from "@brika/schema/store"; +import { compilePluginGate } from "@brika/compiler/v8"; +import { + type ActionEntry, + type ManifestValidator, + REGISTRY_LIMITS, + readTarGzEntries, +} from "@brika/registry-core"; +import { + RegistryPublishSchema, + storeLocaleOf, + validateManifestCapabilities, + validateManifestFiles, + validateStoreLocales, +} from "@brika/schema/store"; +import { checkPluginCompiles } from "./compile-check"; type TarEntry = Awaited>[number]; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + /** * If the tarball ships its own package.json, its name/version must match the published * manifest: a divergent embedded one would let a consumer who reads the unpacked file see @@ -21,7 +38,7 @@ function checkEmbeddedManifest( } catch { return { ok: false, message: "tarball package.json is not valid JSON" }; } - const record = typeof pkg === "object" && pkg !== null ? (pkg as Record) : {}; + const record: Record = isRecord(pkg) ? pkg : {}; if (record.name !== manifest.name || record.version !== manifest.version) { return { ok: false, @@ -48,7 +65,7 @@ export class SchemaManifestValidator implements ManifestValidator { async validate( manifest: Record, tarball: Uint8Array, - ): Promise<{ ok: true } | { ok: false; message: string }> { + ): Promise<{ ok: true; actions: ActionEntry[] } | { ok: false; message: string }> { const parsed = RegistryPublishSchema.safeParse(manifest); if (!parsed.success) { const issue = parsed.error.issues[0]; @@ -84,7 +101,43 @@ export class SchemaManifestValidator implements ManifestValidator { const embedded = checkEmbeddedManifest(entries, manifest); if (!embedded.ok) return embedded; + // The entrypoint and listing assets the manifest names must actually be in the tarball: a build + // that forgot to include `main`/`icon` is a broken plugin, rejected here rather than at install. + const fileIssue = validateManifestFiles( + parsed.data, + entries.map((entry) => ({ path: entry.path, size: entry.data.length })), + )[0]; + if (fileIssue !== undefined) return { ok: false, message: fileIssue }; + + // Every capability the manifest declares must be backed by code: reject a manifest that claims + // bricks/blocks/etc. it never ships (a fake plugin), matching how the Brika compiler discovers them. + const capabilityIssue = validateManifestCapabilities(parsed.data, entries)[0]; + if (capabilityIssue !== undefined) return { ok: false, message: capabilityIssue }; + + // Breadth: every shipped source must be real, compilable code. Cheap per-file sucrase pass, so + // even a file the bundle below never reaches (a dead util, a stubbed server-action helper) is + // still syntax-checked. A garbage source is rejected here. + const compiled = checkPluginCompiles(entries); + if (!compiled.ok) return { ok: false, message: compiled.message }; + + // Depth: bundle the plugin's whole declared graph - the server `main`, every brick/page/block - + // so unresolved imports (which per-file syntax cannot see) are caught, on server code too. The + // isolate gate is pure JS (rollup + sucrase, runs in the Worker) and as a byproduct discovers the + // plugin's server actions for the report. All shipped plugins compile clean under this. const decoder = new TextDecoder(); + const sources = new Map(entries.map((entry) => [entry.path, decoder.decode(entry.data)])); + const entrypoints = [ + String(parsed.data.main).replace(/^\.?\//, ""), + ...(parsed.data.bricks ?? []).map((brick) => `src/bricks/${brick.id}.tsx`), + ...(parsed.data.pages ?? []).map((page) => `src/pages/${page.id}.tsx`), + ...(parsed.data.blocks ?? []).flatMap((block) => [ + `src/blocks/${block.id}.ts`, + `src/blocks/${block.id}.view.tsx`, + ]), + ].filter((path) => sources.has(path)); + const gate = await compilePluginGate({ sources, entrypoints }); + if (!gate.ok) return { ok: false, message: gate.error }; + const localeFiles = entries .filter((entry) => storeLocaleOf(entry.path) !== null) .map((entry) => ({ path: entry.path, text: decoder.decode(entry.data) })); @@ -94,6 +147,13 @@ export class SchemaManifestValidator implements ManifestValidator { if (first !== undefined) { return { ok: false, message: `${first.path}: ${first.message}` }; } - return { ok: true }; + return { + ok: true, + actions: gate.report.actions.map((action) => ({ + file: action.file, + name: action.name, + actionId: action.actionId, + })), + }; } } diff --git a/apps/registry/src/adapters/publish-notifier.test.ts b/apps/registry/src/adapters/publish-notifier.test.ts new file mode 100644 index 00000000..a11b5c9c --- /dev/null +++ b/apps/registry/src/adapters/publish-notifier.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { type Db, regScopeMembers, regScopes } from "@brika/store-db"; +import { makeDb } from "@brika/store-db/test-harness"; +import { sql } from "drizzle-orm"; +import { emailPublishNotifier } from "./publish-notifier"; + +let db: Db; +beforeEach(async () => { + db = makeDb(); + await db.run( + sql`CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, email_verified INTEGER NOT NULL DEFAULT 0)`, + ); + await db.insert(regScopes).values({ scope: "@acme" }); + await db.insert(regScopeMembers).values({ scope: "@acme", userId: "u1", role: "admin" }); + await db.run(sql`INSERT INTO users (id, email, email_verified) VALUES ('u1', 'a@x.com', 1)`); +}); + +const cfg = { + resendApiKey: "key", + from: "Brika ", + storeUrl: "https://store.brika.dev", +}; +const event = { name: "@acme/plugin", version: "1.2.0", publishedBy: "u1" }; + +function captureFetch() { + const calls: Array<{ url: string; body: unknown }> = []; + const fetchImpl: typeof fetch = (url, init) => { + calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? "null")) }); + return Promise.resolve(new Response(null, { status: 200 })); + }; + return { calls, fetchImpl }; +} + +describe("emailPublishNotifier", () => { + test("sends to verified scope members with a formatted message", async () => { + const { calls, fetchImpl } = captureFetch(); + await emailPublishNotifier(db, cfg, fetchImpl).notifyPublished(event); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("https://api.resend.com/emails"); + const body = calls[0]?.body as { to: string[]; subject: string; text: string }; + expect(body.to).toEqual(["a@x.com"]); + expect(body.subject).toBe("@acme/plugin@1.2.0 published"); + expect(body.text).toContain("https://store.brika.dev/@acme/plugin"); + }); + + test("no-op when the API key is empty", async () => { + const { calls, fetchImpl } = captureFetch(); + await emailPublishNotifier(db, { ...cfg, resendApiKey: "" }, fetchImpl).notifyPublished(event); + expect(calls).toHaveLength(0); + }); + + test("no send when the scope has no recipients (and never throws)", async () => { + const { calls, fetchImpl } = captureFetch(); + await emailPublishNotifier(db, cfg, fetchImpl).notifyPublished({ + ...event, + name: "@empty/plugin", + }); + expect(calls).toHaveLength(0); + }); +}); diff --git a/apps/registry/src/adapters/publish-notifier.ts b/apps/registry/src/adapters/publish-notifier.ts new file mode 100644 index 00000000..fc3add47 --- /dev/null +++ b/apps/registry/src/adapters/publish-notifier.ts @@ -0,0 +1,66 @@ +import { type PublishedEvent, type PublishNotifier, scopeOf } from "@brika/registry-core"; +import type { Db } from "@brika/store-db"; +import { scopeMemberEmails } from "@brika/store-db/adapters"; + +/** + * Publish-email adapter. On a successful publish, emails the scope's verified members via Resend's + * HTTP API (dependency-free, works on Workers). A no-op when no API key is set, so email is opt-in and + * a publish never depends on it. Swap the `send` call for a Cloudflare Email binding to go native. + */ + +export interface EmailConfig { + /** Resend API key. Empty disables sending entirely (the notifier becomes a no-op). */ + readonly resendApiKey: string; + /** RFC 5322 `From`, e.g. `Brika `. */ + readonly from: string; + /** Store origin, used to build the "view plugin" link in the message. */ + readonly storeUrl: string; +} + +async function sendViaResend( + cfg: EmailConfig, + to: readonly string[], + subject: string, + text: string, + fetchImpl: typeof fetch, +): Promise { + const res = await fetchImpl("https://api.resend.com/emails", { + method: "POST", + headers: { + authorization: `Bearer ${cfg.resendApiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ from: cfg.from, to: [...to], subject, text }), + }); + if (!res.ok) throw new Error(`resend send failed (${res.status})`); +} + +/** + * Build the notifier. `fetchImpl` is injectable so the send path is testable without a live API. + * `notifyPublished` swallows its own errors: notification failures must never surface to a publish. + */ +export function emailPublishNotifier( + db: Db, + cfg: EmailConfig, + fetchImpl: typeof fetch = fetch, +): PublishNotifier { + return { + async notifyPublished(event: PublishedEvent): Promise { + if (cfg.resendApiKey === "") return; + const scope = scopeOf(event.name); + if (scope === null) return; + try { + const recipients = await scopeMemberEmails(db, scope); + if (recipients.length === 0) return; + const subject = `${event.name}@${event.version} published`; + const text = + `${event.publishedBy} published ${event.name}@${event.version}.\n\n` + + `${cfg.storeUrl}/${event.name}\n\n` + + "If this was not expected, review your scope's members."; + await sendViaResend(cfg, recipients, subject, text, fetchImpl); + } catch (error) { + console.error("publish notification failed", error); + } + }, + }; +} diff --git a/apps/registry/src/controllers/handlers.test.ts b/apps/registry/src/controllers/handlers.test.ts index 590834f1..7c34e927 100644 --- a/apps/registry/src/controllers/handlers.test.ts +++ b/apps/registry/src/controllers/handlers.test.ts @@ -953,3 +953,35 @@ describe("scope publisher (verified attribution)", () => { expect(await res).toBe(403); }); }); + +describe("ResolveService.report", () => { + test("returns a version's manifest capabilities and discovered actions", async () => { + await seedBrikaScope(db, "octocat"); + await db.insert(regPackages).values({ name: "@brika/matter", scope: "@brika" }); + await db.insert(regVersions).values({ + name: "@brika/matter", + version: "1.0.0", + manifest: { name: "@brika/matter", bricks: [{ id: "device", name: "Matter Device" }] }, + actions: [{ file: "src/actions.ts", name: "listDevices", actionId: "0844025241d0" }], + integrity: "sha512-x", + shasum: "x", + size: 1, + publishedAt: "2024-06-01T00:00:00.000Z", + }); + await db.insert(regDistTags).values({ name: "@brika/matter", tag: "latest", version: "1.0.0" }); + + const doc = await run(services(db), () => + inject(ResolveService).report("@brika/matter", "1.0.0"), + ); + expect(doc?.manifest.bricks).toEqual([{ id: "device", name: "Matter Device" }]); + expect(doc?.actions).toEqual([ + { file: "src/actions.ts", name: "listDevices", actionId: "0844025241d0" }, + ]); + }); + + test("returns null for an unknown version", async () => { + await seedPackage(db, "octocat"); + const doc = await run(services(db), () => inject(ResolveService).report("@brika/x", "9.9.9")); + expect(doc).toBeNull(); + }); +}); diff --git a/apps/registry/src/controllers/packages.ts b/apps/registry/src/controllers/packages.ts index 795a11b7..8dc8483a 100644 --- a/apps/registry/src/controllers/packages.ts +++ b/apps/registry/src/controllers/packages.ts @@ -2,6 +2,7 @@ import { inject } from "@brika/di"; import { ResolveService } from "@brika/registry-core"; import { notFound } from "@brika/router"; import { type PackageParams, PKG, packageName } from "@brika/router/npm"; +import { serveCached } from "../http/edge-cache"; import { controller, route } from "../http/router"; import { parseTarballVersion } from "../npm-url"; import { Downloads } from "../services"; @@ -17,20 +18,24 @@ const ABBREVIATED_ACCEPT = "application/vnd.npm.install-v1+json"; async function packument({ params, req, + waitUntil, }: { readonly params: PackageParams; readonly req: Request; + readonly waitUntil: (promise: Promise) => void; }): Promise { const abbreviated = (req.headers.get("accept") ?? "").includes(ABBREVIATED_ACCEPT); - const doc = await inject(ResolveService).packument(packageName(params), { abbreviated }); - if (doc === null) throw notFound(); - return new Response(JSON.stringify(doc), { - status: 200, - headers: { - "content-type": abbreviated ? ABBREVIATED_ACCEPT : "application/json", - "cache-control": "public, max-age=60", - vary: "accept", - }, + return serveCached(req, waitUntil, async () => { + const doc = await inject(ResolveService).packument(packageName(params), { abbreviated }); + if (doc === null) throw notFound(); + return new Response(JSON.stringify(doc), { + status: 200, + headers: { + "content-type": abbreviated ? ABBREVIATED_ACCEPT : "application/json", + "cache-control": "public, max-age=60", + vary: "accept", + }, + }); }); } @@ -41,29 +46,35 @@ async function packument({ */ async function tarball({ params, + req, waitUntil, }: { readonly params: PackageParams & { readonly file: string }; + readonly req: Request; readonly waitUntil: (promise: Promise) => void; }): Promise { const name = packageName(params); const version = parseTarballVersion(name, params.file); if (version === null) throw notFound(); - const stream = await inject(ResolveService).tarball(name, version); - if (stream === null) throw notFound(); + // Count every request off the response path. A cache hit still runs the Worker, so this keeps + // counting; the record for a rare bad-version 404 is a negligible over-count. waitUntil( inject(Downloads) .record(name) .catch(() => {}), ); - return new Response(stream, { - status: 200, - headers: { - "content-type": "application/octet-stream", - "cache-control": "public, max-age=31536000, immutable", - }, + return serveCached(req, waitUntil, async () => { + const stream = await inject(ResolveService).tarball(name, version); + if (stream === null) throw notFound(); + return new Response(stream, { + status: 200, + headers: { + "content-type": "application/octet-stream", + "cache-control": "public, max-age=31536000, immutable", + }, + }); }); } diff --git a/apps/registry/src/controllers/publish.ts b/apps/registry/src/controllers/publish.ts index 87bd772d..301b2105 100644 --- a/apps/registry/src/controllers/publish.ts +++ b/apps/registry/src/controllers/publish.ts @@ -1,9 +1,13 @@ -import { inject } from "@brika/di"; +import { Buffer } from "node:buffer"; +import { inject, injectOr } from "@brika/di"; import { isTrustedLogEntry, + noopPublishNotifier, type PublishErrorCode, type PublishIdentity, + PublishNotifier, PublishService, + REGISTRY_LIMITS, sha512Integrity, TransparencyEntry, } from "@brika/registry-core"; @@ -20,11 +24,15 @@ import { Audit } from "../services"; * All publish logic + invariants live in `PublishService`; this is the wiring. */ +// Base64 inflates by 4/3; cap the field so an oversized upload is rejected before it is decoded and +// hashed, rather than after (the decoded-byte limit still applies in PublishService). +const MAX_TARBALL_BASE64 = Math.ceil(REGISTRY_LIMITS.maxTarballBytes / 3) * 4 + 4; + const PublishBody = z.object({ name: z.string(), version: z.string(), manifest: z.record(z.string(), z.unknown()), - tarball: z.string(), + tarball: z.string().max(MAX_TARBALL_BASE64), /** Optional sigstore/transparency-log entry created by the CLI in CI. */ transparencyLog: TransparencyEntry.optional(), }); @@ -48,13 +56,6 @@ async function withAttestation( return { ...identity, provenance: { ...identity.provenance, transparencyLog: entry } }; } -function base64ToBytes(value: string): Uint8Array { - const binary = atob(value); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.codePointAt(i) ?? 0; - return bytes; -} - function statusForPublishError(code: PublishErrorCode): number { switch (code) { case "forbidden": @@ -74,16 +75,20 @@ function statusForPublishError(code: PublishErrorCode): number { export async function publish({ body, req, + waitUntil, }: { readonly body: z.infer; readonly req: Request; + readonly waitUntil: (promise: Promise) => void; }): Promise { const publishService = inject(PublishService); const audit = inject(Audit); const identity = await requireWrite(req); const { name, version, manifest, tarball, transparencyLog } = body; - const tarballBytes = base64ToBytes(tarball); + // Lenient base64 decode: malformed input yields bytes that fail the gzip/tar gate downstream (a + // clean 400), so no need to guard `atob` throwing here. + const tarballBytes = Buffer.from(tarball, "base64"); const publisher = await withAttestation(identity, tarballBytes, transparencyLog); // In a transaction so a failed metadata commit rolls the staged tarball back: all-or-nothing across R2 + D1. @@ -106,6 +111,16 @@ export async function publish({ }); if (!result.ok) throw httpError(statusForPublishError(result.code), result.message, result.code); + + // Notify off the response path: a mail failure must never fail or slow a publish. + waitUntil( + injectOr(PublishNotifier, noopPublishNotifier).notifyPublished({ + name, + version, + publishedBy: identity.userId ?? identity.repository ?? "unknown", + }), + ); + return reply({ ok: true, name, version, integrity: result.integrity }, 201); } diff --git a/apps/registry/src/controllers/report.ts b/apps/registry/src/controllers/report.ts new file mode 100644 index 00000000..224da793 --- /dev/null +++ b/apps/registry/src/controllers/report.ts @@ -0,0 +1,35 @@ +import { inject } from "@brika/di"; +import { ResolveService } from "@brika/registry-core"; +import { notFound } from "@brika/router"; +import { type PackageParams, PKG, packageName } from "@brika/router/npm"; +import { serveCached } from "../http/edge-cache"; +import { controller, route } from "../http/router"; + +/** + * `GET /:name/:version/report`. The compile-time report for a published version: its declared + * capabilities (bricks/blocks/pages/sparks/tools) plus the server actions discovered at publish. + * Derived once at publish and immutable per version, so cacheable; a later takedown 404s it. + */ +async function report({ + params, + req, + waitUntil, +}: { + readonly params: PackageParams & { readonly version: string }; + readonly req: Request; + readonly waitUntil: (promise: Promise) => void; +}): Promise { + return serveCached(req, waitUntil, async () => { + const doc = await inject(ResolveService).report(packageName(params), params.version); + if (doc === null) throw notFound(); + return new Response(JSON.stringify(doc), { + status: 200, + headers: { "content-type": "application/json", "cache-control": "public, max-age=300" }, + }); + }); +} + +export const reportController = controller({ + name: "report", + routes: [route.get({ path: `/${PKG}/:version/report`, handler: report })], +}); diff --git a/apps/registry/src/controllers/search.ts b/apps/registry/src/controllers/search.ts index af690483..01b4c9bb 100644 --- a/apps/registry/src/controllers/search.ts +++ b/apps/registry/src/controllers/search.ts @@ -6,6 +6,7 @@ import { type SortClause, } from "@brika/registry-core"; import { z } from "zod"; +import { serveCached } from "../http/edge-cache"; import { controller, route } from "../http/router"; import { Downloads, Search } from "../services"; @@ -83,13 +84,23 @@ async function withDownloads(entries: readonly CatalogEntry[]) { return entries.map((entry) => ({ ...entry, downloads: stats.get(entry.name) })); } -export async function handleSearch(request: Request): Promise { - const { text, ...query } = SearchParams.parse( - Object.fromEntries(new URL(request.url).searchParams), - ); - const { entries, total } = await inject(Search).search({ q: text, ...query }); - const packages = await withDownloads(entries); - return Response.json({ packages, total }, { headers: { "cache-control": "public, max-age=60" } }); +export function handleSearch( + request: Request, + // Defaults to a no-op so tests can call it without a Worker execution context; production always + // passes the real `waitUntil` (used to populate the edge cache off the response path). + waitUntil: (promise: Promise) => void = () => {}, +): Promise { + return serveCached(request, waitUntil, async () => { + const { text, ...query } = SearchParams.parse( + Object.fromEntries(new URL(request.url).searchParams), + ); + const { entries, total } = await inject(Search).search({ q: text, ...query }); + const packages = await withDownloads(entries); + return Response.json( + { packages, total }, + { headers: { "cache-control": "public, max-age=60" } }, + ); + }); } export const searchController = controller({ @@ -97,7 +108,7 @@ export const searchController = controller({ prefix: "/-/v1", routes: [ // `/packages` is the no-filter enumerate; both are served by the same SQL-backed search. - route.get({ path: "/packages", handler: ({ req }) => handleSearch(req) }), - route.get({ path: "/search", handler: ({ req }) => handleSearch(req) }), + route.get({ path: "/packages", handler: ({ req, waitUntil }) => handleSearch(req, waitUntil) }), + route.get({ path: "/search", handler: ({ req, waitUntil }) => handleSearch(req, waitUntil) }), ], }); diff --git a/apps/registry/src/env.ts b/apps/registry/src/env.ts index be86e497..d51e3c7b 100644 --- a/apps/registry/src/env.ts +++ b/apps/registry/src/env.ts @@ -23,6 +23,9 @@ export const vars = defineEnv( // Deploy target. Defaults to `production`; local `.dev.vars` sets `development` to opt into // debug aids (e.g. serving the error stack on a 500). Safe by default: prod never opts in. ENVIRONMENT: z.enum(["development", "production"]).default("production"), + // Publish-email (Resend). Empty API key -> notifications are disabled (the default). + RESEND_API_KEY: z.string().default(""), + EMAIL_FROM: z.string().default("Brika "), }, () => env, ); diff --git a/apps/registry/src/http/edge-cache.ts b/apps/registry/src/http/edge-cache.ts new file mode 100644 index 00000000..c514d957 --- /dev/null +++ b/apps/registry/src/http/edge-cache.ts @@ -0,0 +1,25 @@ +/** + * Cloudflare Cache API helper for the hot read paths. A Worker on a custom domain does NOT get its + * responses cached automatically, so `cache-control` headers alone never skip the Worker's D1 + R2 + * work. Explicitly consulting `caches.default` does: reads still run the Worker (so download counts + * keep incrementing), but a hit returns the stored response without touching D1 or R2. + */ + +/** + * Serve `req` from the edge cache when present, else run `produce` and cache a successful, cacheable + * response off the response path. A no-op passthrough when `caches` is absent (e.g. unit tests under + * bun, which has no Cache API global). + */ +export async function serveCached( + req: Request, + waitUntil: (promise: Promise) => void, + produce: () => Promise, +): Promise { + const cache = typeof caches === "undefined" ? undefined : caches.default; + if (cache === undefined) return produce(); + const hit = await cache.match(req); + if (hit !== undefined) return hit; + const res = await produce(); + if (res.ok) waitUntil(cache.put(req, res.clone())); + return res; +} diff --git a/apps/registry/src/index.ts b/apps/registry/src/index.ts index 554188c8..3f533189 100644 --- a/apps/registry/src/index.ts +++ b/apps/registry/src/index.ts @@ -9,6 +9,7 @@ import { deviceController } from "./controllers/device"; import { manageController } from "./controllers/manage"; import { packagesController } from "./controllers/packages"; import { publishController } from "./controllers/publish"; +import { reportController } from "./controllers/report"; import { scopeController } from "./controllers/scope"; import { searchController } from "./controllers/search"; import { statsController } from "./controllers/stats"; @@ -25,6 +26,11 @@ function withRegistry(bindings: Cloudflare.Env, baseUrl: string, fn: () => R) baseUrl, admins: registryAdmins(), domainSecret: vars().DOMAIN_VERIFY_SECRET, + email: { + resendApiKey: vars().RESEND_API_KEY, + from: vars().EMAIL_FROM, + storeUrl: vars().STORE_URL, + }, }), fn, ); @@ -55,6 +61,7 @@ const controllers = [ deviceController, manageController, scopeController, + reportController, packagesController, ]; diff --git a/apps/registry/src/services.ts b/apps/registry/src/services.ts index fff20a6b..f644073b 100644 --- a/apps/registry/src/services.ts +++ b/apps/registry/src/services.ts @@ -4,6 +4,7 @@ import { type DownloadStore, ManifestValidator, MetadataWriter, + PublishNotifier, RegistryBaseUrl, TarballReader, TarballScanner, @@ -19,6 +20,7 @@ import { } from "@brika/store-db/adapters"; import { SchemaManifestValidator } from "./adapters/manifest-validator"; import { NoopTarballScanner } from "./adapters/noop-tarball-scanner"; +import { type EmailConfig, emailPublishNotifier } from "./adapters/publish-notifier"; import { R2TarballReader, TarballBucket } from "./adapters/r2-tarball"; import { R2TarballWriter } from "./adapters/r2-tarball-writer"; @@ -39,6 +41,8 @@ export interface RegistryConfig { readonly baseUrl: string; readonly admins?: ReadonlySet; readonly domainSecret?: string; + /** Publish-email config. Absent (or an empty API key) disables notifications. */ + readonly email?: EmailConfig; } /** @@ -69,5 +73,12 @@ export function provideRegistry(config: RegistryConfig): Provider[] { { provide: TarballScanner, useClass: NoopTarballScanner }, { provide: DeviceStore, useClass: D1DeviceStore }, { provide: Downloads, useClass: D1DownloadStore }, + { + provide: PublishNotifier, + useValue: emailPublishNotifier( + db, + config.email ?? { resendApiKey: "", from: "", storeUrl: baseUrl }, + ), + }, ]; } diff --git a/apps/web/src/lib/registry/manifest-mapping.test.ts b/apps/web/src/lib/registry/manifest-mapping.test.ts new file mode 100644 index 00000000..bcb54b02 --- /dev/null +++ b/apps/web/src/lib/registry/manifest-mapping.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { httpUrl } from "@/lib/registry/manifest-mapping"; + +describe("httpUrl", () => { + test("keeps http(s) URLs", () => { + expect(httpUrl("https://example.com")).toBe("https://example.com"); + expect(httpUrl("http://example.com/x")).toBe("http://example.com/x"); + }); + + test("drops non-http(s) and malformed URLs (XSS guard)", () => { + expect(httpUrl("javascript:alert(1)")).toBeUndefined(); + expect(httpUrl("data:text/html,")).toBeUndefined(); + expect(httpUrl("not a url")).toBeUndefined(); + expect(httpUrl("")).toBeUndefined(); + expect(httpUrl(undefined)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/registry/manifest-mapping.ts b/apps/web/src/lib/registry/manifest-mapping.ts index e6a292bb..71709aff 100644 --- a/apps/web/src/lib/registry/manifest-mapping.ts +++ b/apps/web/src/lib/registry/manifest-mapping.ts @@ -90,6 +90,21 @@ export function repoUrl(repo: Repository | undefined): string | undefined { return undefined; } +/** + * Normalize a free-form homepage to an http(s) URL, or undefined. Manifests come from untrusted + * publishers (and the npm mirror), so anything not http(s), e.g. a `javascript:` URL, is dropped + * before it can become an `` on the plugin page. + */ +export function httpUrl(raw: string | undefined): string | undefined { + if (raw === undefined || raw.length === 0) return undefined; + try { + const { protocol } = new URL(raw); + return protocol === "http:" || protocol === "https:" ? raw : undefined; + } catch { + return undefined; + } +} + /** The capability counts a manifest declares (absent arrays count as 0). */ export function capabilityCounts(manifest: { tools?: unknown[]; diff --git a/apps/web/src/lib/registry/registry-http.ts b/apps/web/src/lib/registry/registry-http.ts index 4cbb69eb..a4c7c5cb 100644 --- a/apps/web/src/lib/registry/registry-http.ts +++ b/apps/web/src/lib/registry/registry-http.ts @@ -10,8 +10,9 @@ import type { z } from "zod"; export const REGISTRY_SCOPE = "@brika/"; /** Public origin of the registry. Overridable for local dev via Vite env. */ +const envRegistryUrl = import.meta.env?.VITE_REGISTRY_URL; export const REGISTRY_ORIGIN: string = new URL( - (import.meta.env?.VITE_REGISTRY_URL as string | undefined) ?? "https://registry.brika.dev", + typeof envRegistryUrl === "string" ? envRegistryUrl : "https://registry.brika.dev", ).origin; /** True for names hosted on our registry (the `@brika` scope). */ diff --git a/apps/web/src/lib/registry/registry-mappers.ts b/apps/web/src/lib/registry/registry-mappers.ts index 13387433..8d5eebcd 100644 --- a/apps/web/src/lib/registry/registry-mappers.ts +++ b/apps/web/src/lib/registry/registry-mappers.ts @@ -9,6 +9,7 @@ import { } from "@brika/registry-contract"; import { capabilityCounts, + httpUrl, mapScreenshots, personName, repoUrl, @@ -64,7 +65,7 @@ export function manifestToDetail( installs: options.installs, brikaEngine, repository: repoUrl(manifest.repository), - homepage: manifest.homepage, + homepage: httpUrl(manifest.homepage), license: manifest.license, capabilities: capabilityCounts(manifest), grants: manifest.grants ?? {}, diff --git a/apps/web/src/routes/auth/logout.ts b/apps/web/src/routes/auth/logout.ts index 46251507..dfb5297f 100644 --- a/apps/web/src/routes/auth/logout.ts +++ b/apps/web/src/routes/auth/logout.ts @@ -5,7 +5,15 @@ import { signOut } from "@/lib/auth/auth"; export const Route = createFileRoute("/auth/logout")({ server: { handlers: { - GET: ({ request }) => signOut(request), + // Reject cross-site navigations so a third-party page can't force-logout a visitor (CSRF). + // Same-origin link clicks send `same-origin`; a bookmark/typed URL sends `none` (both allowed); + // an attacker navigating the top window to us sends `cross-site`. + GET: ({ request }) => { + if (request.headers.get("sec-fetch-site") === "cross-site") { + return new Response(null, { status: 303, headers: { location: "/" } }); + } + return signOut(request); + }, }, }, }); diff --git a/apps/web/src/routes/plugins/index.tsx b/apps/web/src/routes/plugins/index.tsx index 01f2f98f..534057e8 100644 --- a/apps/web/src/routes/plugins/index.tsx +++ b/apps/web/src/routes/plugins/index.tsx @@ -32,13 +32,15 @@ export const Route = createFileRoute("/plugins/")({ sort: deps.sort, verified: verifiedOnly ? true : undefined, }; - const page = await searchPlugins(deps.q, PAGE_SIZE, deps.offset ?? 0, filters).then( - withRatings, - ); - // The discovery rails only exist on the unfiltered Explore view. - const railsPlugins = explore - ? (await searchPlugins(undefined, RAILS_SCAN, 0, { verified: filters.verified })).plugins - : []; + // The grid page and the Explore-only rails are independent, so fetch them concurrently. + const [page, railsPlugins] = await Promise.all([ + searchPlugins(deps.q, PAGE_SIZE, deps.offset ?? 0, filters).then(withRatings), + explore + ? searchPlugins(undefined, RAILS_SCAN, 0, { verified: filters.verified }).then( + (r) => r.plugins, + ) + : Promise.resolve([]), + ]); return { ...page, railsPlugins, verifiedOnly }; }, component: BrowsePage, diff --git a/bun.lock b/bun.lock index 345ac56e..c5b322cd 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "name": "@brika/registry", "version": "0.1.0", "dependencies": { + "@brika/compiler": "^0.4.0-00e5f35c9ec7", "@brika/di": "workspace:*", "@brika/env": "workspace:*", "@brika/registry-core": "workspace:*", @@ -44,6 +45,7 @@ "@brika/tx": "workspace:*", "drizzle-orm": "^0.45.2", "hono": "^4.12.25", + "sucrase": "^3.35.1", "zod": "^4.4.3", }, "devDependencies": { @@ -320,6 +322,8 @@ "@brika/cli-kit": ["@brika/cli-kit@workspace:packages/cli-kit"], + "@brika/compiler": ["@brika/compiler@0.4.0-00e5f35c9ec7", "", { "dependencies": { "@rollup/browser": "^3.30.0", "sucrase": "^3.35.1", "zod": "^4.4.3" } }, "sha512-L2+vrlDZ1vQ1UWpGennb2qCUBMaDsLV/y0b62Z6QD71UQmLlyoslG0U3qa0zDXtfo21nOcFoixTVuIYHo77DLw=="], + "@brika/di": ["@brika/di@workspace:packages/di"], "@brika/env": ["@brika/env@workspace:packages/env"], @@ -510,7 +514,7 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], @@ -692,6 +696,8 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rollup/browser": ["@rollup/browser@3.30.0", "", {}, "sha512-4nRvRGMjGXUBgAwW71QTbBiQBnsVZWpEXS9ExBXTippMxk2tTXNPQTnZCBqrqZIaiifD9KrNPkLfjgHm+8UbfQ=="], + "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], @@ -856,6 +862,8 @@ "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -902,6 +910,8 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -1084,6 +1094,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -1184,12 +1196,16 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="], "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], @@ -1204,6 +1220,8 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "playwright": ["playwright@1.61.0", "", { "dependencies": { "playwright-core": "1.61.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ=="], "playwright-core": ["playwright-core@1.61.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA=="], @@ -1302,6 +1320,8 @@ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -1310,6 +1330,10 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -1318,6 +1342,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], @@ -1394,8 +1420,6 @@ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/template/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -1404,11 +1428,9 @@ "@brika/clay/lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - - "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], diff --git a/packages/db/drizzle/0005_perf_indexes.sql b/packages/db/drizzle/0005_perf_indexes.sql new file mode 100644 index 00000000..c593e9e5 --- /dev/null +++ b/packages/db/drizzle/0005_perf_indexes.sql @@ -0,0 +1,9 @@ +CREATE INDEX `reg_tokens_user_id_idx` ON `reg_tokens` (`user_id`); +--> statement-breakpoint +CREATE INDEX `reg_scope_members_user_id_idx` ON `reg_scope_members` (`user_id`); +--> statement-breakpoint +CREATE INDEX `reg_packages_scope_idx` ON `reg_packages` (`scope`); +--> statement-breakpoint +CREATE INDEX `reg_audit_at_idx` ON `reg_audit` (`at`); +--> statement-breakpoint +CREATE INDEX `reg_audit_action_idx` ON `reg_audit` (`action`); diff --git a/packages/db/drizzle/0006_version_actions.sql b/packages/db/drizzle/0006_version_actions.sql new file mode 100644 index 00000000..551b65a4 --- /dev/null +++ b/packages/db/drizzle/0006_version_actions.sql @@ -0,0 +1 @@ +ALTER TABLE `reg_versions` ADD `actions` text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 12be1e1f..e70bcf85 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -36,6 +36,20 @@ "when": 1781997227131, "tag": "0004_package_takedown", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1781997227132, + "tag": "0005_perf_indexes", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1781997227133, + "tag": "0006_version_actions", + "breakpoints": true } ] } diff --git a/packages/db/src/adapters/d1-downloads.ts b/packages/db/src/adapters/d1-downloads.ts index aae2a08c..4ce07c92 100644 --- a/packages/db/src/adapters/d1-downloads.ts +++ b/packages/db/src/adapters/d1-downloads.ts @@ -1,10 +1,12 @@ import { inject, injectOr, token } from "@brika/di"; import { + DOWNLOAD_WINDOW_DAYS, type DownloadStats, type DownloadStore, downloadSeries, epochDay, summarizeDownloads, + ZERO_DOWNLOADS, } from "@brika/registry-core"; import { eq, inArray, sql } from "drizzle-orm"; import { Db } from "../client"; @@ -53,20 +55,22 @@ export class D1DownloadStore implements DownloadStore { async statsFor(names: readonly string[]): Promise> { const result = new Map(); if (names.length === 0) return result; + // Aggregate in SQL (SUM + a windowed SUM) rather than shipping every per-day row to the Worker: + // a search page over 250 packages otherwise transfers all their history to sum it in JS. + const cutoff = epochDay(this.#now()) - (DOWNLOAD_WINDOW_DAYS - 1); const rows = await this.#db - .select({ name: regDownloads.name, day: regDownloads.day, count: regDownloads.count }) + .select({ + name: regDownloads.name, + total: sql`sum(${regDownloads.count})`, + weekly: sql`sum(case when ${regDownloads.day} >= ${cutoff} then ${regDownloads.count} else 0 end)`, + }) .from(regDownloads) - .where(inArray(regDownloads.name, [...names])); + .where(inArray(regDownloads.name, [...names])) + .groupBy(regDownloads.name); - const byName = new Map(); + for (const name of names) result.set(name, ZERO_DOWNLOADS); for (const row of rows) { - const list = byName.get(row.name) ?? []; - list.push({ day: row.day, count: row.count }); - byName.set(row.name, list); - } - const today = epochDay(this.#now()); - for (const name of names) { - result.set(name, summarizeDownloads(byName.get(name) ?? [], today)); + result.set(row.name, { total: Number(row.total), weekly: Number(row.weekly) }); } return result; } diff --git a/packages/db/src/adapters/d1-metadata-writer.test.ts b/packages/db/src/adapters/d1-metadata-writer.test.ts index 7e0596e2..81aaeb22 100644 --- a/packages/db/src/adapters/d1-metadata-writer.test.ts +++ b/packages/db/src/adapters/d1-metadata-writer.test.ts @@ -131,3 +131,37 @@ describe("D1MetadataWriter packageExists / createPackage (name reservation)", () ).toEqual([]); }); }); + +describe("D1MetadataWriter commitVersion actions", () => { + test("round-trips discovered actions through the version report", async () => { + await writer.commitVersion({ + scope: "@brika", + tag: "latest", + version: { + name: NAME, + version: "3.0.0", + manifest: {}, + actions: [{ file: "src/actions.ts", name: "listDevices", actionId: "0844025241d0" }], + integrity: "sha512-c", + shasum: "c", + size: 1, + publishedAt: "2024-06-03T00:00:00.000Z", + deprecated: null, + yanked: false, + takedownReason: null, + provenance: null, + }, + }); + const record = await makeAdapter(db, D1MetadataReader).getPackage(NAME); + const committed = record?.versions.find((v) => v.version === "3.0.0"); + expect(committed?.actions).toEqual([ + { file: "src/actions.ts", name: "listDevices", actionId: "0844025241d0" }, + ]); + }); + + test("a version committed without actions reads back as an empty list", async () => { + // Legacy rows (seeded in beforeEach with no `actions`) must not be null. + const record = await makeAdapter(db, D1MetadataReader).getPackage(NAME); + expect(record?.versions.find((v) => v.version === "1.0.0")?.actions).toEqual([]); + }); +}); diff --git a/packages/db/src/adapters/d1-metadata-writer.ts b/packages/db/src/adapters/d1-metadata-writer.ts index 098e2662..ee9982dd 100644 --- a/packages/db/src/adapters/d1-metadata-writer.ts +++ b/packages/db/src/adapters/d1-metadata-writer.ts @@ -51,6 +51,7 @@ export class D1MetadataWriter implements MetadataWriter, VersionManager { deprecated: version.deprecated, yanked: version.yanked, provenance: version.provenance ?? undefined, + actions: version.actions ?? [], }), this.#db .insert(regDistTags) diff --git a/packages/db/src/adapters/d1-metadata.ts b/packages/db/src/adapters/d1-metadata.ts index 63fd1d93..d9c9c9e9 100644 --- a/packages/db/src/adapters/d1-metadata.ts +++ b/packages/db/src/adapters/d1-metadata.ts @@ -13,6 +13,19 @@ import { regDistTags, regPackages, regScopes, regVersions } from "../schema"; export class D1MetadataReader implements MetadataReader { readonly #db = inject(Db); + /** Existence + takedown (own or scope) in one row, no version manifests loaded. */ + async takedownState(name: string): Promise { + const rows = await this.#db + .select({ takedown: regPackages.takedown, scopeTakedown: regScopes.takedown }) + .from(regPackages) + .leftJoin(regScopes, eq(regPackages.scope, regScopes.scope)) + .where(eq(regPackages.name, name)) + .limit(1); + const row = rows[0]; + if (row === undefined) return null; + return row.takedown !== null || row.scopeTakedown !== null; + } + async getPackage(name: string): Promise { const packageRows = await this.#db .select() @@ -50,6 +63,7 @@ export class D1MetadataReader implements MetadataReader { name: row.name, version: row.version, manifest: row.manifest, + actions: row.actions ?? [], integrity: row.integrity, shasum: row.shasum, size: row.size, diff --git a/packages/db/src/adapters/d1-search.test.ts b/packages/db/src/adapters/d1-search.test.ts index b2410a36..d133ac7a 100644 --- a/packages/db/src/adapters/d1-search.test.ts +++ b/packages/db/src/adapters/d1-search.test.ts @@ -263,6 +263,7 @@ describe("0001 migration backfill", () => { apply(sqlite, "0002_verified.sql"); // before the seed: drizzle inlines the verified default on insert apply(sqlite, "0003_scope_verified.sql"); // the search reader joins reg_scopes.verified (the org badge) apply(sqlite, "0004_package_takedown.sql"); // the current search reader filters on reg_packages.takedown + apply(sqlite, "0006_version_actions.sql"); // the seed inserts through the current schema, which has reg_versions.actions // Seed legacy rows through Drizzle (so the manifest JSON is serialized correctly) before the // search migration runs, then apply it and assert the backfill projected them. const db = drizzle(sqlite, { schema }) as unknown as Db; diff --git a/packages/db/src/adapters/index.ts b/packages/db/src/adapters/index.ts index c684eec3..0f0608a4 100644 --- a/packages/db/src/adapters/index.ts +++ b/packages/db/src/adapters/index.ts @@ -21,5 +21,6 @@ export { resolveActor, revokeTokenByHash, type SubjectToken, + scopeMemberEmails, } from "./queries"; export { D1TokenStore, issueToken, revokeToken, verifyToken } from "./token"; diff --git a/packages/db/src/adapters/queries.test.ts b/packages/db/src/adapters/queries.test.ts index bd30bd91..383e04c0 100644 --- a/packages/db/src/adapters/queries.test.ts +++ b/packages/db/src/adapters/queries.test.ts @@ -16,6 +16,7 @@ import { listSubjectTokens, resolveActor, revokeTokenByHash, + scopeMemberEmails, } from "./queries"; let db: Db; @@ -247,3 +248,37 @@ describe("resolveActor", () => { expect(await resolveActor(db, "u1")).toEqual({ displayName: "Mona Lisa", avatarUrl: null }); }); }); + +describe("scopeMemberEmails", () => { + beforeEach(async () => { + await db.run( + sql`CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, email_verified INTEGER NOT NULL DEFAULT 0)`, + ); + }); + async function account(id: string, email: string | null, verified: boolean) { + await db.run( + sql`INSERT INTO users (id, email, email_verified) VALUES (${id}, ${email}, ${verified ? 1 : 0})`, + ); + } + + test("returns only verified members' emails for the scope", async () => { + await scope("@acme"); + await scope("@other"); + await member("@acme", "u1", "admin"); + await member("@acme", "u2", "member"); + await member("@acme", "u3", "member"); // unverified -> excluded + await member("@other", "u4", "admin"); // different scope -> excluded + await account("u1", "a@x.com", true); + await account("u2", "b@x.com", true); + await account("u3", "c@x.com", false); + await account("u4", "d@x.com", true); + expect((await scopeMemberEmails(db, "@acme")).sort()).toEqual(["a@x.com", "b@x.com"]); + }); + + test("empty when the scope has no verified members", async () => { + await scope("@acme"); + await member("@acme", "u1", "admin"); + await account("u1", null, true); + expect(await scopeMemberEmails(db, "@acme")).toEqual([]); + }); +}); diff --git a/packages/db/src/adapters/queries.ts b/packages/db/src/adapters/queries.ts index faac1b9e..bc718e8a 100644 --- a/packages/db/src/adapters/queries.ts +++ b/packages/db/src/adapters/queries.ts @@ -184,6 +184,27 @@ export async function resolveActor( return { displayName: display || name || null, avatarUrl: image }; } +/** + * Verified email addresses of a scope's members, for publish notifications. Joins `reg_scope_members` + * to the store's `users` table (same D1, read with raw SQL like {@link resolveActor}). Best-effort: + * any read error resolves to an empty list, so a publish is never blocked by a mail-lookup failure. + */ +export async function scopeMemberEmails(db: Db, scope: string): Promise { + try { + const rows = await db.all<{ email: string | null }>( + sql`SELECT u.email AS email + FROM reg_scope_members m + JOIN users u ON u.id = m.user_id + WHERE m.scope = ${scope} AND u.email_verified = 1 AND u.email IS NOT NULL`, + ); + return rows + .map((row) => row.email) + .filter((email): email is string => typeof email === "string" && email.length > 0); + } catch { + return []; + } +} + /** * A publish token's metadata. Only the SHA-256 `tokenHash` is stored (plaintext is shown once, * never persisted), so the UI identifies a token by a hash prefix plus these timestamps. diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 66b21b6a..af708b94 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,4 +1,4 @@ -import { type Actor, Provenance, scopeLinkSchema } from "@brika/registry-core"; +import { ActionEntry, type Actor, Provenance, scopeLinkSchema } from "@brika/registry-core"; import { sql } from "drizzle-orm"; import { customType, index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { z } from "zod"; @@ -77,6 +77,8 @@ export const regVersions = sqliteTable( takedown: text("takedown"), /** CI build provenance from the GitHub OIDC token; null for local publishes. */ provenance: jsonOrNull(Provenance)("provenance"), + /** Server actions discovered at publish; null for versions published before action scanning. */ + actions: jsonOrNull(z.array(ActionEntry))("actions"), }, (t) => [primaryKey({ columns: [t.name, t.version] })], ); diff --git a/packages/registry-core/src/index.ts b/packages/registry-core/src/index.ts index 4541fbb1..3f4b41ac 100644 --- a/packages/registry-core/src/index.ts +++ b/packages/registry-core/src/index.ts @@ -43,6 +43,11 @@ export { REGISTRY_LIMITS, type RegistryLimits } from "./limits"; export { ManagementService, type ManageResult, VersionManager } from "./manage"; export { type ScopeMember, ScopeMembers, type ScopeRole } from "./membership"; export { isCanonicalName, isCanonicalScope, scopeOf } from "./names"; +export { + noopPublishNotifier, + type PublishedEvent, + PublishNotifier, +} from "./notify"; export { BaseClaims, GITHUB_ISSUER, @@ -97,7 +102,12 @@ export { TarballScanner, TarballWriter, } from "./publish"; -export { type PackumentOptions, RegistryBaseUrl, ResolveService } from "./resolve"; +export { + type PackumentOptions, + type PluginReport, + RegistryBaseUrl, + ResolveService, +} from "./resolve"; export { ClaimVerifier, DnsResolver, @@ -135,6 +145,7 @@ export { trustedPublisherSchema, } from "./trusted-publishers"; export { + ActionEntry, type PackageRecord, PackageVersion, Provenance, diff --git a/packages/registry-core/src/notify.ts b/packages/registry-core/src/notify.ts new file mode 100644 index 00000000..564b338c --- /dev/null +++ b/packages/registry-core/src/notify.ts @@ -0,0 +1,25 @@ +import { token } from "@brika/di"; + +/** + * Publish notifications. A side-channel signal ("a new version shipped") the registry fires after a + * successful publish, e.g. to email a scope's members. A port so the core/controller stay free of any + * mail transport, and so publishing never depends on notifications being configured. + */ + +/** The payload of a publish notification: what was published, and by whom (for the message body). */ +export interface PublishedEvent { + readonly name: string; + readonly version: string; + /** Display name or id of who published, shown in the notification. */ + readonly publishedBy: string; +} + +export interface PublishNotifier { + /** Notify about a successful publish. Best-effort: implementations must never throw into the caller. */ + notifyPublished(event: PublishedEvent): Promise; +} +/** Optional DI token for the {@link PublishNotifier} port; defaults to {@link noopPublishNotifier}. */ +export const PublishNotifier = token("PublishNotifier"); + +/** Default when nothing is bound: publishing proceeds without sending anything. */ +export const noopPublishNotifier: PublishNotifier = { notifyPublished: () => Promise.resolve() }; diff --git a/packages/registry-core/src/ports.ts b/packages/registry-core/src/ports.ts index 47f437f6..fd0dbc34 100644 --- a/packages/registry-core/src/ports.ts +++ b/packages/registry-core/src/ports.ts @@ -9,6 +9,11 @@ import type { PackageRecord } from "./types"; /** Read access to package metadata, used to resolve packuments. */ export interface MetadataReader { getPackage(name: string): Promise; + /** + * Whether a package is taken down (its own or its scope's), without loading every version. `null` + * when the package is unknown. Lets the hot tarball path skip parsing all version manifests. + */ + takedownState(name: string): Promise; } /** DI token for the {@link MetadataReader} port. */ export const MetadataReader = token("MetadataReader"); diff --git a/packages/registry-core/src/publish.test.ts b/packages/registry-core/src/publish.test.ts index cc8da759..a1b47744 100644 --- a/packages/registry-core/src/publish.test.ts +++ b/packages/registry-core/src/publish.test.ts @@ -42,7 +42,9 @@ const deny: OwnershipPolicy = { const rejectingScanner: TarballScanner = { scan: () => Promise.resolve({ ok: false, message: "matched signature EICAR-Test" }), }; -const validManifest: ManifestValidator = { validate: () => Promise.resolve({ ok: true }) }; +const validManifest: ManifestValidator = { + validate: () => Promise.resolve({ ok: true, actions: [] }), +}; const invalidManifest: ManifestValidator = { validate: () => Promise.resolve({ ok: false, message: "icon required" }), }; diff --git a/packages/registry-core/src/publish.ts b/packages/registry-core/src/publish.ts index 8eacf3a8..12901777 100644 --- a/packages/registry-core/src/publish.ts +++ b/packages/registry-core/src/publish.ts @@ -3,7 +3,7 @@ import { sha1Hex, sha512Integrity } from "./integrity"; import { REGISTRY_LIMITS } from "./limits"; import { isCanonicalName, scopeOf } from "./names"; import { tarballPath } from "./packument"; -import type { PackageVersion, Provenance } from "./types"; +import type { ActionEntry, PackageVersion, Provenance } from "./types"; /** * Who is publishing. INVARIANT: a publish is EITHER a human (Brika `userId`) OR CI (OIDC `provider` + @@ -40,10 +40,11 @@ export type PublishErrorCode = "forbidden" | "invalid" | "exists" | "too_large" * so `@brika/schema` stays the single source of truth and the core stays free of schema imports. */ export interface ManifestValidator { + /** Validate + compile-gate a plugin. On accept, returns the actions the gate discovered. */ validate( manifest: Record, tarball: Uint8Array, - ): Promise<{ ok: true } | { ok: false; message: string }>; + ): Promise<{ ok: true; actions: ActionEntry[] } | { ok: false; message: string }>; } /** DI token for the {@link ManifestValidator} port (an app binds the `@brika/schema` adapter). */ export const ManifestValidator = token("ManifestValidator"); @@ -150,7 +151,8 @@ export class PublishService { }; } - // 3. Manifest/data gate (required metadata, bundled locale files, etc.). + // 3. Manifest/data + compile gate. On accept it also returns the discovered actions + // (the gate compiles the plugin once), which we store with the version for its report. const valid = await this.#validator.validate(input.manifest, input.tarball); if (!valid.ok) return { ok: false, code: "invalid", message: valid.message }; @@ -183,6 +185,7 @@ export class PublishService { name: input.name, version: input.version, manifest: input.manifest, + actions: valid.actions, integrity, shasum, size, diff --git a/packages/registry-core/src/resolve.test.ts b/packages/registry-core/src/resolve.test.ts index a829b0e1..a0cd6be6 100644 --- a/packages/registry-core/src/resolve.test.ts +++ b/packages/registry-core/src/resolve.test.ts @@ -35,6 +35,12 @@ function record(over: Partial = {}): PackageRecord { function makeService(rec: PackageRecord | null) { const meta: MetadataReader = { getPackage: (name) => Promise.resolve(rec !== null && name === rec.name ? rec : null), + takedownState: (name) => + Promise.resolve( + rec === null || name !== rec.name + ? null + : rec.takedown !== null || rec.scopeTakedown !== null, + ), }; const tarballs: TarballReader = { get: (key) => diff --git a/packages/registry-core/src/resolve.ts b/packages/registry-core/src/resolve.ts index 06df0ebf..34d522f3 100644 --- a/packages/registry-core/src/resolve.ts +++ b/packages/registry-core/src/resolve.ts @@ -7,11 +7,19 @@ import { tarballPath, } from "./packument"; import { MetadataReader, TarballReader } from "./ports"; -import type { PackageRecord } from "./types"; +import type { ActionEntry, PackageRecord } from "./types"; /** Public base URL of the registry, e.g. `https://registry.brika.dev` - injected by the app. */ export const RegistryBaseUrl = token("RegistryBaseUrl"); +/** A published version's compile report: its declared capabilities plus discovered server actions. */ +export interface PluginReport { + readonly name: string; + readonly version: string; + readonly manifest: Record; + readonly actions: readonly ActionEntry[]; +} + export interface PackumentOptions { /** Return the abbreviated install metadata instead of the full document. */ readonly abbreviated?: boolean; @@ -38,14 +46,27 @@ export class ResolveService { : buildPackument(record, this.#baseUrl); } + /** The compile report (capabilities + actions) for one version, or null when unknown/taken down. */ + async report(name: string, version: string): Promise { + const record = await this.#meta.getPackage(name); + if (record === null || isTakenDown(record)) return null; + const found = record.versions.find( + (entry) => entry.version === version && entry.takedownReason === null, + ); + if (found === undefined) return null; + return { name, version, manifest: found.manifest, actions: found.actions ?? [] }; + } + /** * Stream a tarball by package name and version, or null when absent or taken down. An operator * takedown of the whole package or its scope also withdraws the bytes (unlike a yank, which keeps * them for pinned lockfiles), so a direct tarball URL 404s too. */ async tarball(name: string, version: string): Promise | null> { - const record = await this.#meta.getPackage(name); - if (record === null || isTakenDown(record)) return null; + // Cheap 2-column check instead of loading + parsing every version's manifest: this is the + // hottest endpoint, and it only needs to know the package exists and is not taken down. + const takenDown = await this.#meta.takedownState(name); + if (takenDown !== false) return null; return this.#tarballs.get(tarballPath(name, version)); } } diff --git a/packages/registry-core/src/tar.ts b/packages/registry-core/src/tar.ts index 12745992..9861d71b 100644 --- a/packages/registry-core/src/tar.ts +++ b/packages/registry-core/src/tar.ts @@ -8,10 +8,11 @@ import { REGISTRY_LIMITS } from "./limits"; const BLOCK = 512; const PACKAGE_PREFIX = "package/"; -// Hard ceiling on decompressed bytes. Generous over maxUnpackedBytes (file-data sum is re-checked -// downstream) to leave room for tar headers + padding, while still aborting a decompression bomb -// long before a Worker's ~128 MB heap. A ~20 MiB compressed bomb expands to GiB without this. -const MAX_DECOMPRESSED_BYTES = REGISTRY_LIMITS.maxUnpackedBytes * 2; +// Hard ceiling on decompressed bytes: the unpacked-size limit plus 8 MiB of slack for tar headers + +// padding (the file-data sum is re-checked downstream). Kept close to the real limit rather than a +// loose 2x so a decompression bomb aborts well short of a Worker's ~128 MB heap. A ~20 MiB +// compressed bomb expands to GiB without this. +const MAX_DECOMPRESSED_BYTES = REGISTRY_LIMITS.maxUnpackedBytes + 8 * 1024 * 1024; // USTAR header field offsets and lengths within a 512-byte block. const NAME_OFFSET = 0; const NAME_LEN = 100; diff --git a/packages/registry-core/src/types.ts b/packages/registry-core/src/types.ts index 47b30d21..441a3eb0 100644 --- a/packages/registry-core/src/types.ts +++ b/packages/registry-core/src/types.ts @@ -15,11 +15,21 @@ export const Provenance = z.object({ }); export type Provenance = z.infer; +/** One server action discovered in a plugin's sources: an internal RPC endpoint (not in the manifest). */ +export const ActionEntry = z.object({ + file: z.string(), // source path, e.g. src/actions.ts + name: z.string(), // exported action name (`default` for a default export) + actionId: z.string(), // the id the runtime dispatches on (sha256(file\0name), 12 hex) +}); +export type ActionEntry = z.infer; + /** A single published, immutable package version, as held by the metadata store. */ export const PackageVersion = z.object({ name: z.string(), version: z.string(), manifest: z.record(z.string(), z.unknown()), + /** Actions discovered at publish time. Absent on versions published before action scanning. */ + actions: z.array(ActionEntry).optional(), integrity: z.string(), // sha512-... shasum: z.string(), // legacy SHA-1 hex (dist.shasum) size: z.number().int().nonnegative(), // bytes diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index b8f7adb9..076be696 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -185,10 +185,9 @@ function defineRoute< /** Parse the JSON body against a schema (throwing a 400 on failure), or pass `undefined`. */ async function parseBody(c: Context, schema: z.ZodType | undefined): Promise { if (schema === undefined) return undefined; - const raw: unknown = await c.req.raw - .clone() - .json() - .catch(() => null); + // No `.clone()`: only schema'd routes reach here and none re-read the raw body, so cloning just + // double-buffers the request (costly for the large base64 publish body). + const raw: unknown = await c.req.raw.json().catch(() => null); const parsed = schema.safeParse(raw); if (!parsed.success) throw badRequest("Invalid request body"); return parsed.data; diff --git a/packages/schema/src/store.test.ts b/packages/schema/src/store.test.ts index e682e047..4419ca7a 100644 --- a/packages/schema/src/store.test.ts +++ b/packages/schema/src/store.test.ts @@ -3,6 +3,8 @@ import { RegistryPublishSchema, StoreLocaleSchema, storeLocaleOf, + validateManifestCapabilities, + validateManifestFiles, validateStoreLocales, } from "./store"; @@ -116,6 +118,66 @@ test("validateStoreLocales reports a bad locale tag, invalid JSON, and schema mi ]); }); +test("validateManifestFiles accepts a manifest whose referenced files are all present", () => { + const manifest = RegistryPublishSchema.parse({ ...publishable, screenshots: [{ src: "1.png" }] }); + const issues = validateManifestFiles(manifest, [ + { path: "src/index.ts", size: 12 }, // main "./src/index.ts" -> tarball-relative + { path: "assets/icon.svg", size: 30 }, // icon "./assets/icon.svg" + { path: "1.png", size: 100 }, + ]); + expect(issues).toEqual([]); +}); + +test("validateManifestFiles reports main, icon, and screenshots missing from the tarball", () => { + const manifest = RegistryPublishSchema.parse({ ...publishable, screenshots: [{ src: "1.png" }] }); + const issues = validateManifestFiles(manifest, []); + expect(issues).toHaveLength(3); + expect(issues.join("\n")).toContain("main"); + expect(issues.join("\n")).toContain("icon"); + expect(issues.join("\n")).toContain("screenshots[0].src"); +}); + +test("validateManifestFiles rejects an empty main entrypoint", () => { + const manifest = RegistryPublishSchema.parse(publishable); + const issues = validateManifestFiles(manifest, [ + { path: "src/index.ts", size: 0 }, + { path: "assets/icon.svg", size: 30 }, + ]); + expect(issues).toHaveLength(1); + expect(issues[0]).toContain("empty"); +}); + +test("validateManifestCapabilities accepts declarations backed by source files (id = basename)", () => { + const manifest = RegistryPublishSchema.parse({ + ...publishable, + bricks: [{ id: "current" }, { id: "forecast" }], + blocks: [{ id: "timer", category: "action" }], + pages: [{ id: "settings" }], + sparks: [{ id: "a" }, { id: "b" }], // single multi-capability src/sparks.ts + tools: [{ id: "t" }], + }); + const issues = validateManifestCapabilities(manifest, [ + { path: "src/bricks/current.tsx" }, + { path: "src/bricks/forecast.brick.ts" }, // .brick.ts backing is also accepted + { path: "src/blocks/timer.ts" }, + { path: "src/pages/settings.tsx" }, + { path: "src/sparks.ts" }, + { path: "src/tools/t.ts" }, + ]); + expect(issues).toEqual([]); +}); + +test("validateManifestCapabilities reports a declared capability with no backing code", () => { + const manifest = RegistryPublishSchema.parse({ + ...publishable, + bricks: [{ id: "current" }, { id: "ghost" }], + }); + const issues = validateManifestCapabilities(manifest, [{ path: "src/bricks/current.tsx" }]); + expect(issues).toHaveLength(1); + expect(issues[0]).toContain("ghost"); + expect(issues[0]).toContain("src/bricks/ghost.tsx"); +}); + test("validateStoreLocales rejects more captions than screenshots", () => { const issues = validateStoreLocales( [ diff --git a/packages/schema/src/store.ts b/packages/schema/src/store.ts index fc4ce3a5..13b1b9f8 100644 --- a/packages/schema/src/store.ts +++ b/packages/schema/src/store.ts @@ -149,3 +149,95 @@ export const RegistryPublishSchema = PluginPackageSchema.extend({ }); export type RegistryPublishManifest = z.infer; + +// ============================================================================ +// Referenced-file validation (the paths the manifest points into the tarball) +// ============================================================================ + +/** Strip a leading "./" or "/" so a manifest reference matches a tarball-relative path. */ +function tarRelative(path: string): string { + return path.replace(/^\.?\//, ""); +} + +/** + * Every path the manifest names (the `main` entrypoint, `icon`, and each screenshot) must resolve to a + * file bundled in the tarball, and `main` must be non-empty. Catches a build that ships a manifest + * pointing at files it forgot to include, which would otherwise only break at install/load time. + * `files` are tarball-relative (the `package/` prefix already stripped); returns one message per miss. + */ +export function validateManifestFiles( + manifest: RegistryPublishManifest, + files: ReadonlyArray<{ path: string; size: number }>, +): string[] { + const sizeByPath = new Map(files.map((file) => [file.path, file.size])); + const refs = [ + { field: "main", path: manifest.main }, + { field: "icon", path: manifest.icon }, + ...(manifest.screenshots ?? []).map((shot, index) => ({ + field: `screenshots[${index}].src`, + path: shot.src, + })), + ]; + const issues: string[] = []; + for (const ref of refs) { + const size = sizeByPath.get(tarRelative(ref.path)); + if (size === undefined) { + issues.push(`${ref.field} "${ref.path}" is not in the tarball`); + } else if (ref.field === "main" && size === 0) { + issues.push(`${ref.field} "${ref.path}" is empty`); + } + } + return issues; +} + +// ============================================================================ +// Declared-capability validation (every declared capability must be backed by code) +// ============================================================================ + +/** + * The source file(s) the Brika compiler discovers a capability `id` from, by convention (the id is + * the file basename). Sparks/tools may be one file per id (`src//.ts`) OR many ids in a + * single `src/.ts`; the single-file form defines its ids in code, so accept its presence. + */ +const CAPABILITY_SOURCES = { + brick: (id: string) => [`src/bricks/${id}.tsx`, `src/bricks/${id}.brick.ts`], + block: (id: string) => [`src/blocks/${id}.ts`], + page: (id: string) => [`src/pages/${id}.tsx`], + spark: (id: string) => [`src/sparks/${id}.ts`, "src/sparks.ts"], + tool: (id: string) => [`src/tools/${id}.ts`, "src/tools.ts"], +} as const; + +/** + * Every capability the manifest DECLARES (brick/block/spark/page/tool) must be backed by a source + * file where the Brika compiler would look for it. This rejects the "fake plugin" case, a manifest + * that claims capabilities it never ships (to squat a name or look richer than it is), which the + * referenced-file and compile checks miss. It does NOT verify capability metadata (name/category), + * which only the real compiler can by executing the module, nor the reverse (code present but + * under-declared), which is a completeness concern and would false-positive on helper modules. + * `files` are tarball-relative (the `package/` prefix already stripped); returns one message per miss. + */ +export function validateManifestCapabilities( + manifest: RegistryPublishManifest, + files: ReadonlyArray<{ path: string }>, +): string[] { + const present = new Set(files.map((file) => file.path)); + const declared = [ + { kind: "brick", entries: manifest.bricks }, + { kind: "block", entries: manifest.blocks }, + { kind: "page", entries: manifest.pages }, + { kind: "spark", entries: manifest.sparks }, + { kind: "tool", entries: manifest.tools }, + ] as const; + const issues: string[] = []; + for (const { kind, entries } of declared) { + for (const entry of entries ?? []) { + const candidates = CAPABILITY_SOURCES[kind](entry.id); + if (!candidates.some((path) => present.has(path))) { + issues.push( + `${kind} "${entry.id}" is declared but its source is missing from the tarball (expected ${candidates.join(" or ")})`, + ); + } + } + } + return issues; +} diff --git a/packages/tx/src/adapters/storage.test.ts b/packages/tx/src/adapters/storage.test.ts index 7da1ac14..f12d893b 100644 --- a/packages/tx/src/adapters/storage.test.ts +++ b/packages/tx/src/adapters/storage.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { requiresNew } from "../core/propagation"; import { transaction } from "../core/transaction"; import { InMemoryFiles } from "../testing/in-memory-files"; import { type FileStore, transactionalStorage } from "./storage"; @@ -49,25 +48,6 @@ describe("transactionalStorage", () => { expect(raw.objects.size).toBe(0); }); - test("a requiresNew put survives an outer rollback", async () => { - const files = new InMemoryFiles(); - const tx = transactionalStorage(files); - await expect( - transaction(async () => { - await tx.put("outer.tgz", "x"); - await transaction( - async () => { - await tx.put("inner.tgz", "y"); - }, - { propagation: requiresNew }, - ); - throw new Error("outer fails"); - }), - ).rejects.toThrow("outer fails"); - expect(files.has("outer.tgz")).toBe(false); // rolled back - expect(files.has("inner.tgz")).toBe(true); // independent inner committed - }); - test("preserves the wrapped store's other methods (the overlay is transparent)", async () => { const files = new InMemoryFiles(); const tx = transactionalStorage(files); diff --git a/packages/tx/src/core/manager.ts b/packages/tx/src/core/manager.ts index dbfccdcf..61b2eea5 100644 --- a/packages/tx/src/core/manager.ts +++ b/packages/tx/src/core/manager.ts @@ -65,11 +65,8 @@ export class TransactionManager { const scope: Scope = { active: this.active, open: (work) => this.#open(work, config), - suspend: (work) => this.#context.run(undefined, work), join: (work) => work(), }; - // `async` so a strategy that throws synchronously (mandatory/never) rejects - // rather than throwing at the call site. return propagation(fn, scope); } diff --git a/packages/tx/src/core/propagation.test.ts b/packages/tx/src/core/propagation.test.ts index dee14cda..8359ac0e 100644 --- a/packages/tx/src/core/propagation.test.ts +++ b/packages/tx/src/core/propagation.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { TransactionError } from "./errors"; -import { mandatory, never, notSupported, required, requiresNew, supports } from "./propagation"; +import { required } from "./propagation"; import { inTransaction, onRollback, transaction } from "./transaction"; describe("required", () => { @@ -37,137 +36,3 @@ describe("required", () => { expect(undone).toEqual(["inner", "outer"]); // single scope, reverse order }); }); - -describe("requiresNew", () => { - test("commits independently of an outer rollback", async () => { - const events: string[] = []; - await expect( - transaction(async () => { - onRollback(() => events.push("outer-undo")); - await transaction( - async () => { - onRollback(() => events.push("inner-undo")); // inner succeeds -> not run - }, - { propagation: requiresNew }, - ); - throw new Error("outer fails"); - }), - ).rejects.toThrow("outer fails"); - expect(events).toEqual(["outer-undo"]); // inner committed; only outer rolled back - }); - - test("its own rollback does not touch the outer transaction", async () => { - const events: string[] = []; - await transaction(async () => { - onRollback(() => events.push("outer-undo")); // outer commits -> not run - await transaction( - async () => { - onRollback(() => events.push("inner-undo")); - throw new Error("inner fails"); - }, - { propagation: requiresNew }, - ).catch(() => events.push("caught")); - }); - expect(events).toEqual(["inner-undo", "caught"]); // inner rolled back; outer committed - }); - - test("is active inside (a fresh transaction)", async () => { - let active = false; - await transaction(async () => { - await transaction( - async () => { - active = inTransaction(); - }, - { propagation: requiresNew }, - ); - }); - expect(active).toBe(true); - }); -}); - -describe("mandatory", () => { - test("throws outside a transaction", async () => { - await expect(transaction(async () => {}, { propagation: mandatory })).rejects.toBeInstanceOf( - TransactionError, - ); - }); - - test("runs inside a transaction", async () => { - let ran = false; - await transaction(async () => { - await transaction( - async () => { - ran = true; - }, - { propagation: mandatory }, - ); - }); - expect(ran).toBe(true); - }); -}); - -describe("never", () => { - test("runs outside a transaction", async () => { - let ran = false; - await transaction( - async () => { - ran = true; - }, - { propagation: never }, - ); - expect(ran).toBe(true); - }); - - test("throws inside a transaction", async () => { - await expect( - transaction(async () => { - await transaction(async () => {}, { propagation: never }); - }), - ).rejects.toBeInstanceOf(TransactionError); - }); -}); - -describe("supports", () => { - test("runs without a transaction outside one", async () => { - let active = true; - await transaction( - async () => { - active = inTransaction(); - }, - { propagation: supports }, - ); - expect(active).toBe(false); - }); - - test("joins an active transaction", async () => { - let active = false; - await transaction(async () => { - await transaction( - async () => { - active = inTransaction(); - }, - { propagation: supports }, - ); - }); - expect(active).toBe(true); - }); -}); - -describe("notSupported", () => { - test("suspends the active transaction; its hooks become no-ops and the outer is untouched", async () => { - const events: string[] = []; - let suspendedActive = true; - await transaction(async () => { - onRollback(() => events.push("outer-undo")); // outer commits -> not run - await transaction( - async () => { - suspendedActive = inTransaction(); - onRollback(() => events.push("suspended-undo")); // no active tx -> no-op - }, - { propagation: notSupported }, - ); - }); - expect(suspendedActive).toBe(false); - expect(events).toEqual([]); - }); -}); diff --git a/packages/tx/src/core/propagation.ts b/packages/tx/src/core/propagation.ts index d14abc75..4fc1dd32 100644 --- a/packages/tx/src/core/propagation.ts +++ b/packages/tx/src/core/propagation.ts @@ -1,37 +1,11 @@ -import { TransactionError } from "./errors"; import type { Scope } from "./scope"; /** - * How a `transaction` relates to an already-active one (the Spring propagation modes). A strategy - * value, not a string union: each is a function you pass, so there is no central `switch` to keep exhaustive. + * How a `transaction` relates to an already-active one. A strategy value, not a string union: it is a + * function you pass. Only `required` (the default) is used; add other modes here if a caller needs one. */ export type Propagation = (fn: () => Promise, scope: Scope) => Promise; /** Join the active transaction, or open one. The default. */ export const required: Propagation = (fn, scope) => scope.active ? scope.join(fn) : scope.open(fn); - -/** Always run in a fresh, independent transaction, suspending any active one. */ -export const requiresNew: Propagation = (fn, scope) => scope.suspend(() => scope.open(fn)); - -/** Require an active transaction; throw if there is none. */ -export const mandatory: Propagation = (fn, scope) => { - if (!scope.active) { - throw new TransactionError("propagation 'mandatory' requires an active transaction"); - } - return scope.join(fn); -}; - -/** Forbid an active transaction; throw if one exists. */ -export const never: Propagation = (fn, scope) => { - if (scope.active) { - throw new TransactionError("propagation 'never' forbids an active transaction"); - } - return scope.join(fn); -}; - -/** Join an active transaction if present, else run without one. */ -export const supports: Propagation = (fn, scope) => scope.join(fn); - -/** Suspend any active transaction and run without one. */ -export const notSupported: Propagation = (fn, scope) => scope.suspend(fn); diff --git a/packages/tx/src/core/scope.ts b/packages/tx/src/core/scope.ts index 27f64c44..46cc9826 100644 --- a/packages/tx/src/core/scope.ts +++ b/packages/tx/src/core/scope.ts @@ -8,8 +8,6 @@ export interface Scope { readonly active: boolean; /** Run `fn` in a fresh compensating scope (commit on success, roll back on throw). */ open(fn: () => Promise): Promise; - /** Run `fn` with no transaction, suspending any active one. */ - suspend(fn: () => Promise): Promise; /** Run `fn` as-is in the current context (joining an active tx, or none). */ join(fn: () => Promise): Promise; } diff --git a/packages/tx/src/index.ts b/packages/tx/src/index.ts index 2f1148da..f32bb851 100644 --- a/packages/tx/src/index.ts +++ b/packages/tx/src/index.ts @@ -1,34 +1,7 @@ +/** + * Public API of `@brika/tx`. Deliberately narrow: only the primitives the codebase consumes. The + * fuller machinery (propagation modes, commit/completion hooks, the decorator, the manager) stays + * internal, tested under `src/core`; promote a symbol here when something outside actually needs it. + */ export { type Batchable, type TransactionalDb, transactionalDb } from "./adapters/orm"; -export { type FileStore, transactionalStorage } from "./adapters/storage"; -export { TransactionError } from "./core/errors"; -export { TransactionManager } from "./core/manager"; -export { - mandatory, - never, - notSupported, - type Propagation, - required, - requiresNew, - supports, -} from "./core/propagation"; -export type { Scope } from "./core/scope"; -export { - afterCommit, - inTransaction, - isReadOnly, - onCommit, - onComplete, - onRollback, - readOnlyTransaction, - type TxOptions, - transaction, - transactional, - transactions, -} from "./core/transaction"; -export type { - CommitAction, - Compensation, - CompletionAction, - TxConfig, - TxOutcome, -} from "./core/types"; +export { onRollback, readOnlyTransaction, transaction } from "./core/transaction"; diff --git a/sonar-project.properties b/sonar-project.properties index 686f2e09..86b0b772 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -40,7 +40,7 @@ sonar.javascript.lcov.reportPaths=coverage/lcov.info # - @brika/i18n React layer (react.ts): thin hooks (provider/useT/useLocale + # useMemo wrappers over the tested pure formatters in index.ts), like apps/web # React; the logic (createTranslator, format*, buildCatalogs) stays in coverage. -sonar.coverage.exclusions=apps/web/**,apps/cli/**,packages/cli-kit/**,packages/markers/src/cli.ts,packages/markers/src/extension.ts,packages/i18n/src/react.ts,apps/registry/scripts/**,**/db/src/client.ts,apps/*/src/index.ts,**/test-harness.ts,**/*.config.ts,**/*.config.tsx,**/*.gen.ts,**/*.d.ts +sonar.coverage.exclusions=apps/web/**,apps/cli/**,packages/cli-kit/**,packages/markers/src/cli.ts,packages/markers/src/extension.ts,packages/i18n/src/react.ts,apps/registry/scripts/**,apps/registry/bench/**,**/db/src/client.ts,apps/*/src/index.ts,**/test-harness.ts,**/*.config.ts,**/*.config.tsx,**/*.gen.ts,**/*.d.ts # The scan does not wait on the built-in "Sonar way" gate; check-sonar-gate.ts # is the authority and enforces only Brika's thresholds (coverage/issues/