diff --git a/.changeset/calm-registry-first-releases.md b/.changeset/calm-registry-first-releases.md new file mode 100644 index 0000000000..e3c80997f0 --- /dev/null +++ b/.changeset/calm-registry-first-releases.md @@ -0,0 +1,10 @@ +--- +"@emdash-cms/registry-lexicons": minor +"@emdash-cms/registry-client": minor +"@emdash-cms/admin": patch +"emdash": patch +--- + +Adds a fail-closed first-release exemption to the plugin registry's optional minimum release age policy. A package's first release can install immediately only when the aggregator reports exactly one retained release and confirms that it continuously observed the package's release history. + +Existing packages, backfilled packages, and packages with missing or incomplete history remain subject to the configured holdback. Deleted releases still count, and explicit publisher or package exemptions continue to work. diff --git a/apps/aggregator/migrations/0006_release_history.sql b/apps/aggregator/migrations/0006_release_history.sql new file mode 100644 index 0000000000..c848799c5d --- /dev/null +++ b/apps/aggregator/migrations/0006_release_history.sql @@ -0,0 +1,29 @@ +-- Retain package-level release history evidence separately from the current +-- profile/release projections. Existing rows came from an initial backfill or +-- an earlier deployment whose cursor continuity cannot be proven, so they +-- start incomplete and remain subject to the configured release-age holdback. +CREATE TABLE IF NOT EXISTS package_release_history ( + did TEXT NOT NULL, + package TEXT NOT NULL, + release_history_complete INTEGER NOT NULL CHECK (release_history_complete IN (0, 1)), + first_observed_at TEXT NOT NULL, + first_observed_source TEXT NOT NULL CHECK ( + first_observed_source IN ('jetstream', 'backfill', 'unknown') + ), + PRIMARY KEY (did, package) +); + +INSERT OR IGNORE INTO package_release_history ( + did, + package, + release_history_complete, + first_observed_at, + first_observed_source +) +SELECT + did, + slug, + 0, + COALESCE(indexed_at, verified_at), + 'unknown' +FROM packages; diff --git a/apps/aggregator/src/backfill.ts b/apps/aggregator/src/backfill.ts index a8a0ded396..e925dc1cbc 100644 --- a/apps/aggregator/src/backfill.ts +++ b/apps/aggregator/src/backfill.ts @@ -395,6 +395,7 @@ async function paginateAndEnqueue(opts: PaginateOpts): Promise { rkey: parsed.rkey, operation: "create", cid: record.cid, + source: "backfill", }, }); } diff --git a/apps/aggregator/src/env.ts b/apps/aggregator/src/env.ts index f85caadb5d..28d42f0195 100644 --- a/apps/aggregator/src/env.ts +++ b/apps/aggregator/src/env.ts @@ -13,6 +13,13 @@ export interface RecordsJob { rkey: string; operation: "create" | "update" | "delete"; cid: string; + /** + * Identifies whether the aggregator observed this operation from its live, + * cursor-backed stream or reconstructed current state through backfill. + * Missing values come from an older producer during a rolling deployment + * and must be treated as incomplete history. + */ + source?: "jetstream" | "backfill"; /** * The Jetstream-supplied (unverified) record bytes. Compared against the * verified PDS copy after fetch as a Jetstream-correctness signal; the diff --git a/apps/aggregator/src/jetstream-ingestor.ts b/apps/aggregator/src/jetstream-ingestor.ts index 26214544a7..b4b815d8ea 100644 --- a/apps/aggregator/src/jetstream-ingestor.ts +++ b/apps/aggregator/src/jetstream-ingestor.ts @@ -239,6 +239,7 @@ export class JetstreamIngestor { rkey: event.commit.rkey, operation: event.commit.operation, cid: event.commit.operation === "delete" ? "" : event.commit.cid, + source: "jetstream", ...(event.commit.operation !== "delete" ? { jetstreamRecord: event.commit.record } : {}), }; diff --git a/apps/aggregator/src/records-consumer.ts b/apps/aggregator/src/records-consumer.ts index 28c5a97759..2ef0035790 100644 --- a/apps/aggregator/src/records-consumer.ts +++ b/apps/aggregator/src/records-consumer.ts @@ -477,11 +477,25 @@ export async function ingestPackageProfile( updated_at = excluded.updated_at`, ) .bind(job.did, slug, verified.cid, nowIso); + const retainReleaseHistory = db + .prepare( + `INSERT INTO package_release_history + (did, package, release_history_complete, first_observed_at, first_observed_source) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(did, package) DO NOTHING`, + ) + .bind( + job.did, + slug, + job.source === "jetstream" && job.operation === "create" ? 1 : 0, + nowIso, + job.source ?? "unknown", + ); // The revision must exist before the current pointer moves. D1 batches are // transactional, so a failure leaves both the old pointer and old mutable // compatibility row intact. - await db.batch([retainRevision, updateCurrentPackage, moveCurrentPointer]); + await db.batch([retainRevision, updateCurrentPackage, retainReleaseHistory, moveCurrentPointer]); } export async function ingestPackageRelease( @@ -618,10 +632,21 @@ export async function ingestPackageRelease( // roll back together and the message retries to a clean state. Without // the batch, an insert-success / refresh-failure could leave // `packages.latest_version` permanently stale. - const batchResults = await db.batch([ - insertStmt, - refreshPackageLatestStmt(db, job.did, record.package), - ]); + const batchStatements = [insertStmt, refreshPackageLatestStmt(db, job.did, record.package)]; + if (job.source !== "jetstream") { + // A release first encountered outside the cursor-backed stream proves + // that the aggregator cannot claim continuous history for this package. + batchStatements.push( + db + .prepare( + `UPDATE package_release_history + SET release_history_complete = 0 + WHERE did = ? AND package = ?`, + ) + .bind(job.did, record.package), + ); + } + const batchResults = await db.batch(batchStatements); const insertResult = batchResults[0]; if (!insertResult) { // Defensive: D1.batch() guarantees one result per statement; if it @@ -1053,14 +1078,29 @@ async function writeDeadLetter( // envelope of operation+cid so the row is still inspectable. const payload = JSON.stringify(job.jetstreamRecord ?? { operation: job.operation, cid: job.cid }); const payloadBytes = new TextEncoder().encode(payload); - await db + const retainDeadLetter = db .prepare( `INSERT INTO dead_letters (did, collection, rkey, reason, detail, payload, received_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) - .bind(job.did, job.collection, job.rkey, reason, detail, payloadBytes, now.toISOString()) - .run(); + .bind(job.did, job.collection, job.rkey, reason, detail, payloadBytes, now.toISOString()); + const releaseIdentity = + job.collection === NSID.packageRelease ? parseReleaseRkey(job.rkey) : null; + if (!releaseIdentity) { + await retainDeadLetter.run(); + return; + } + const markHistoryIncomplete = db + .prepare( + `INSERT INTO package_release_history + (did, package, release_history_complete, first_observed_at, first_observed_source) + VALUES (?, ?, 0, ?, ?) + ON CONFLICT(did, package) DO UPDATE SET + release_history_complete = 0`, + ) + .bind(job.did, releaseIdentity.pkg, now.toISOString(), job.source ?? "unknown"); + await db.batch([retainDeadLetter, markHistoryIncomplete]); } // ─── Production wiring ───────────────────────────────────────────────────── diff --git a/apps/aggregator/src/routes/xrpc/listing-query.ts b/apps/aggregator/src/routes/xrpc/listing-query.ts index a5a99d7e97..088976abf7 100644 --- a/apps/aggregator/src/routes/xrpc/listing-query.ts +++ b/apps/aggregator/src/routes/xrpc/listing-query.ts @@ -13,6 +13,19 @@ import { } from "../../listing-policy.js"; import { type PackageRow, packageColumns } from "./views.js"; +const RELEASE_HISTORY_COLUMNS_SQL = ` + (SELECT COUNT(*) + FROM releases release_history + WHERE release_history.did = p.did + AND release_history.package = p.slug) AS historical_release_count, + COALESCE( + (SELECT history.release_history_complete + FROM package_release_history history + WHERE history.did = p.did AND history.package = p.slug), + 0 + ) AS release_history_complete +`; + export type PackageLookupResult = | { state: "visible"; row: PackageRow } | { state: "unavailable" } @@ -28,7 +41,7 @@ export async function lookupPackage( if (policy.mode === "projection") { const row = await session .prepare( - `SELECT ${packageColumns("p.")}, p.labels_json + `SELECT ${packageColumns("p.")}, p.labels_json, ${RELEASE_HISTORY_COLUMNS_SQL} FROM public_projection_state projection_state ${ACTIVE_PROJECTION_JOINS_SQL} JOIN public_packages p ON p.generation = projection_state.active_generation @@ -52,7 +65,7 @@ export async function lookupPackage( const row = await session .prepare( - `SELECT ${packageColumns("p.")} + `SELECT ${packageColumns("p.")}, ${RELEASE_HISTORY_COLUMNS_SQL} FROM packages p WHERE p.did = ? AND p.slug = ? AND ${ACTIVE_PROFILE_SQL} diff --git a/apps/aggregator/src/routes/xrpc/views.ts b/apps/aggregator/src/routes/xrpc/views.ts index 719867f461..e8a952c446 100644 --- a/apps/aggregator/src/routes/xrpc/views.ts +++ b/apps/aggregator/src/routes/xrpc/views.ts @@ -46,6 +46,8 @@ export interface PackageRow { verified_at: string; indexed_at: string | null; labels_json?: string; + historical_release_count?: number; + release_history_complete?: number; } /** Subset of columns from `releases` we read for `releaseView`. */ @@ -141,6 +143,12 @@ export function packageView(row: PackageRow): AggregatorDefs.PackageView { if (row.latest_version !== null) { view.latestVersion = row.latest_version; } + if (row.historical_release_count !== undefined) { + view.historicalReleaseCount = row.historical_release_count; + } + if (row.release_history_complete !== undefined) { + view.releaseHistoryComplete = row.release_history_complete === 1; + } return view; } diff --git a/apps/aggregator/test/backfill.test.ts b/apps/aggregator/test/backfill.test.ts index 7bde9d3884..170fb4f4db 100644 --- a/apps/aggregator/test/backfill.test.ts +++ b/apps/aggregator/test/backfill.test.ts @@ -208,6 +208,7 @@ describe("processBackfillJob", () => { rkey: "demo", operation: "create", cid: "bafyc1", + source: "backfill", }); // jetstreamRecord intentionally not set on backfill jobs — the // consumer's DLQ payload field would otherwise mislabel diff --git a/apps/aggregator/test/jetstream-ingestor.test.ts b/apps/aggregator/test/jetstream-ingestor.test.ts index 979dbfed94..852af47c42 100644 --- a/apps/aggregator/test/jetstream-ingestor.test.ts +++ b/apps/aggregator/test/jetstream-ingestor.test.ts @@ -126,6 +126,7 @@ describe("JetstreamIngestor", () => { rkey: "p", operation: "create", cid: "bafyrecord", + source: "jetstream", jetstreamRecord: { slug: "p", license: "MIT" }, }); expect(h.ingestor.currentCursor).toBe(event.time_us); @@ -314,6 +315,7 @@ describe("JetstreamIngestor", () => { rkey: "p", operation: "delete", cid: "", + source: "jetstream", }); expect(h.queue.jobs[0]?.jetstreamRecord).toBeUndefined(); diff --git a/apps/aggregator/test/listing-projection.test.ts b/apps/aggregator/test/listing-projection.test.ts index b3f060bc5e..4d35be5824 100644 --- a/apps/aggregator/test/listing-projection.test.ts +++ b/apps/aggregator/test/listing-projection.test.ts @@ -64,6 +64,9 @@ let upgradeEvidence: { revisionCount: number; currentCid: string | null; invalidExpiryEpoch: number | null; + releaseHistoryComplete: number | null; + firstObservedSource: string | null; + releaseHistoryRows: number; }; beforeAll(async () => { @@ -74,6 +77,7 @@ beforeAll(async () => { "0003_listing_projection.sql", "0004_signed_label_ingest.sql", "0005_restrictive_label_authority.sql", + "0006_release_history.sql", ]); await applyD1Migrations(testEnv.DB, migrations.slice(0, 2)); await testEnv.DB.prepare( @@ -103,6 +107,9 @@ beforeAll(async () => { const projectionMigration = migrations[2]; if (!projectionMigration) throw new Error("projection migration fixture missing"); await applyD1Migrations(testEnv.DB, [projectionMigration], "projection_restart_probe"); + const releaseHistoryMigration = migrations[5]; + if (!releaseHistoryMigration) throw new Error("release history migration fixture missing"); + await applyD1Migrations(testEnv.DB, [releaseHistoryMigration], "release_history_restart_probe"); const revision = await testEnv.DB.prepare( `SELECT COUNT(*) AS revision_count, @@ -125,6 +132,33 @@ beforeAll(async () => { .bind(LABELER_DID, packageProfileUri(DID_A, "legacy")) .first<{ exp_epoch: number | null }>() )?.exp_epoch ?? null, + releaseHistoryComplete: + ( + await testEnv.DB.prepare( + `SELECT release_history_complete FROM package_release_history + WHERE did = ? AND package = 'legacy'`, + ) + .bind(DID_A) + .first<{ release_history_complete: number }>() + )?.release_history_complete ?? null, + firstObservedSource: + ( + await testEnv.DB.prepare( + `SELECT first_observed_source FROM package_release_history + WHERE did = ? AND package = 'legacy'`, + ) + .bind(DID_A) + .first<{ first_observed_source: string }>() + )?.first_observed_source ?? null, + releaseHistoryRows: + ( + await testEnv.DB.prepare( + `SELECT COUNT(*) AS count FROM package_release_history + WHERE did = ? AND package = 'legacy'`, + ) + .bind(DID_A) + .first<{ count: number }>() + )?.count ?? 0, }; }); @@ -141,6 +175,7 @@ beforeEach(async () => { "labels", "release_duplicate_attempts", "releases", + "package_release_history", "packages", "package_profile_heads", "package_profile_revisions", @@ -155,6 +190,9 @@ describe("revision migration and ingest", () => { revisionCount: 1, currentCid: PROFILE_CID_1, invalidExpiryEpoch: null, + releaseHistoryComplete: 0, + firstObservedSource: "unknown", + releaseHistoryRows: 1, }); }); diff --git a/apps/aggregator/test/read-api.test.ts b/apps/aggregator/test/read-api.test.ts index ad18e54d8f..e62d196be2 100644 --- a/apps/aggregator/test/read-api.test.ts +++ b/apps/aggregator/test/read-api.test.ts @@ -39,6 +39,7 @@ beforeEach(async () => { await testEnv.DB.prepare("DELETE FROM label_state").run(); await testEnv.DB.prepare("DELETE FROM labellers").run(); await testEnv.DB.prepare("DELETE FROM releases").run(); + await testEnv.DB.prepare("DELETE FROM package_release_history").run(); await testEnv.DB.prepare("DELETE FROM packages").run(); await testEnv.DB.prepare("DELETE FROM package_profile_heads").run(); await testEnv.DB.prepare("DELETE FROM package_profile_revisions").run(); @@ -141,6 +142,16 @@ async function seedRelease(opts: SeedReleaseOpts): Promise { .run(); } +async function seedReleaseHistory(complete: boolean): Promise { + await testEnv.DB.prepare( + `INSERT INTO package_release_history + (did, package, release_history_complete, first_observed_at, first_observed_source) + VALUES (?, ?, ?, ?, ?)`, + ) + .bind(DID_A, "demo", complete ? 1 : 0, NOW.toISOString(), complete ? "jetstream" : "backfill") + .run(); +} + async function seedTakedown(uri: string, cid: string | null = null): Promise { await testEnv.DB.prepare( `INSERT INTO labellers @@ -206,6 +217,39 @@ describe("getPackage", () => { expect(body.error).toBe("NotFound"); }); + it("reports complete all-time release history including tombstones", async () => { + await seedPackage({ slug: "demo", latestVersion: "2.0.0" }); + await seedRelease({ version: "1.0.0", tombstoned: true }); + await seedRelease({ version: "2.0.0" }); + await seedReleaseHistory(true); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetPackage}?did=${DID_A}&slug=demo`, + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + historicalReleaseCount: 2, + releaseHistoryComplete: true, + }); + }); + + it("marks backfilled release history incomplete", async () => { + await seedPackage({ slug: "demo", latestVersion: "1.0.0" }); + await seedRelease({ version: "1.0.0" }); + await seedReleaseHistory(false); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorGetPackage}?did=${DID_A}&slug=demo`, + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + historicalReleaseCount: 1, + releaseHistoryComplete: false, + }); + }); + it("returns 400 InvalidRequest on missing required params", async () => { const res = await SELF.fetch(`https://test/xrpc/${NSID.aggregatorGetPackage}?did=${DID_A}`); expect(res.status).toBe(400); diff --git a/apps/aggregator/test/records-consumer.test.ts b/apps/aggregator/test/records-consumer.test.ts index f7f46bc899..5c190c2172 100644 --- a/apps/aggregator/test/records-consumer.test.ts +++ b/apps/aggregator/test/records-consumer.test.ts @@ -71,6 +71,7 @@ beforeEach(async () => { "public_packages", "release_duplicate_attempts", "releases", + "package_release_history", "packages", "package_profile_heads", "package_profile_revisions", @@ -169,6 +170,73 @@ describe("ingestPackageProfile", () => { expect(row?.verified_at).toBe(reIngested.toISOString()); }); + it("marks a live profile creation as complete release history", async () => { + await ingestPackageProfile( + testEnv.DB, + jobFor(DID_A, NSID.packageProfile, "demo", { source: "jetstream" }), + fakeVerified(validRecord), + NOW, + ); + + const row = await testEnv.DB.prepare( + `SELECT release_history_complete, first_observed_source + FROM package_release_history WHERE did = ? AND package = ?`, + ) + .bind(DID_A, "demo") + .first<{ release_history_complete: number; first_observed_source: string }>(); + expect(row).toEqual({ + release_history_complete: 1, + first_observed_source: "jetstream", + }); + }); + + it.each([ + ["backfill", { source: "backfill" as const }], + ["an older producer", {}], + ["a live profile update", { source: "jetstream" as const, operation: "update" as const }], + ])("keeps %s history incomplete", async (_name, source) => { + await ingestPackageProfile( + testEnv.DB, + jobFor(DID_A, NSID.packageProfile, "demo", source), + fakeVerified(validRecord), + NOW, + ); + + const row = await testEnv.DB.prepare( + `SELECT release_history_complete FROM package_release_history + WHERE did = ? AND package = ?`, + ) + .bind(DID_A, "demo") + .first<{ release_history_complete: number }>(); + expect(row?.release_history_complete).toBe(0); + }); + + it("never upgrades incomplete history after a later live profile event", async () => { + await ingestPackageProfile( + testEnv.DB, + jobFor(DID_A, NSID.packageProfile, "demo", { source: "backfill" }), + fakeVerified(validRecord), + NOW, + ); + await ingestPackageProfile( + testEnv.DB, + jobFor(DID_A, NSID.packageProfile, "demo", { source: "jetstream" }), + fakeVerified(validRecord), + new Date("2026-05-10T12:00:00.000Z"), + ); + + const row = await testEnv.DB.prepare( + `SELECT release_history_complete, first_observed_source + FROM package_release_history WHERE did = ? AND package = ?`, + ) + .bind(DID_A, "demo") + .first<{ release_history_complete: number; first_observed_source: string }>(); + expect(row).toEqual({ + release_history_complete: 0, + first_observed_source: "backfill", + }); + }); + it("rejects when rkey ≠ record.slug", async () => { const job = jobFor(DID_A, NSID.packageProfile, "different"); await expect( @@ -244,6 +312,33 @@ describe("ingestPackageRelease", () => { expect(row?.version_sort.startsWith("0000000001.0000000010.")).toBe(true); }); + it("marks history incomplete when a release is first encountered by backfill", async () => { + await testEnv.DB.prepare("DELETE FROM package_release_history").run(); + await testEnv.DB.prepare("DELETE FROM packages").run(); + await testEnv.DB.prepare("DELETE FROM package_profile_heads").run(); + await testEnv.DB.prepare("DELETE FROM package_profile_revisions").run(); + await ingestPackageProfile( + testEnv.DB, + jobFor(DID_A, NSID.packageProfile, "demo", { source: "jetstream" }), + fakeVerified(validProfile), + NOW, + ); + await ingestPackageRelease( + testEnv.DB, + jobFor(DID_A, NSID.packageRelease, "demo:1.0.0", { source: "backfill" }), + fakeVerified(makeRelease("1.0.0")), + NOW, + ); + + const history = await testEnv.DB.prepare( + `SELECT release_history_complete FROM package_release_history + WHERE did = ? AND package = ?`, + ) + .bind(DID_A, "demo") + .first<{ release_history_complete: number }>(); + expect(history?.release_history_complete).toBe(0); + }); + it("rejects when rkey ≠ ':'", async () => { const release = makeRelease("1.0.0"); const job = jobFor(DID_A, NSID.packageRelease, "wrong-rkey"); @@ -630,6 +725,44 @@ describe("processMessage dispatcher", () => { expect(await deadLetterCount()).toBe(0); }); + it("makes release history incomplete when a release is dead-lettered", async () => { + await ingestPackageProfile( + testEnv.DB, + jobFor(DID_A, NSID.packageProfile, "demo", { source: "jetstream" }), + fakeVerified({ + $type: NSID.packageProfile, + id: `at://${DID_A}/${NSID.packageProfile}/demo`, + slug: "demo", + type: "emdash-plugin", + license: "MIT", + authors: [{ name: "Tester" }], + security: [{ email: "x@y.test" }], + }), + NOW, + ); + const { deps, cache } = buildDeps({ + fetch: () => Promise.resolve(new Response("", { status: 404 })), + }); + cache.seed(DID_A); + const msg = new FakeMessage(); + + await processMessage( + jobFor(DID_A, NSID.packageRelease, "demo:1.0.0", { source: "jetstream" }), + msg, + deps, + ); + + expect(msg.acked).toBe(1); + expect(await deadLetterCount()).toBe(1); + const history = await testEnv.DB.prepare( + `SELECT release_history_complete FROM package_release_history + WHERE did = ? AND package = ?`, + ) + .bind(DID_A, "demo") + .first<{ release_history_complete: number }>(); + expect(history?.release_history_complete).toBe(0); + }); + it("retries on a network error", async () => { const { deps, cache } = buildDeps({ fetch: () => Promise.reject(new TypeError("connection refused")), diff --git a/docs/src/content/docs/plugins/registry-client.mdx b/docs/src/content/docs/plugins/registry-client.mdx index d9a1b36fd0..be688fa827 100644 --- a/docs/src/content/docs/plugins/registry-client.mdx +++ b/docs/src/content/docs/plugins/registry-client.mdx @@ -83,6 +83,12 @@ The client exposes one method per aggregator query: - **`listReleases({ did, package, limit?, cursor? })`** — releases in descending semantic-version order, including yanked releases. - **`getLatestRelease({ did, package })`** — the highest non-yanked release selected by the aggregator. +`getPackage()` and `resolvePackage()` can return `historicalReleaseCount` and +`releaseHistoryComplete`. These fields describe the aggregator's retained operational history, +not publisher-signed metadata. Treat a count of one as a first release only when +`releaseHistoryComplete` is `true`. Missing or incomplete evidence must not bypass a release-age +policy. + `getPackageStatus()` and `resolvePackageStatus()` wrap their corresponding package queries and map the safe `ListingUnavailable` response to `{ status: "unavailable" }`. A successful result has `{ status: "passed", value }`. Use these methods in a user interface that needs to distinguish an diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index 1c7d047ae8..bd30238bcc 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -653,6 +653,8 @@ emdash({ See [The plugin registry](/plugins/registry/) for the full workflow, trust model, and how to query the registry from your own site. +The release-age policy exempts a package's first release only when the registry reports one retained release and confirms that it observed the package continuously. A backfilled package, a deleted earlier release, or missing history evidence keeps the holdback in force. Explicit publisher and package exemptions apply regardless of history. + ## Database adapters Import the adapters from `emdash/db`: diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx index 80fcf724ee..e13212dc30 100644 --- a/packages/admin/src/components/RegistryPluginDetail.tsx +++ b/packages/admin/src/components/RegistryPluginDetail.tsx @@ -200,9 +200,7 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP // visible. const defaultVersion = React.useMemo(() => { if (!pkg || releases.length === 0) return undefined; - const passes = releases.find((r) => - releasePassesPolicy(r, { did: pkg.did, slug }, config.policy), - ); + const passes = releases.find((r) => releasePassesPolicy(r, pkg, config.policy)); return (passes ?? releases[0])?.version; }, [pkg, releases, slug, config.policy]); @@ -357,8 +355,7 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP })) : []; - const policyOk = - release && pkg ? releasePassesPolicy(release, { did: pkg.did, slug }, config.policy) : true; + const policyOk = release && pkg ? releasePassesPolicy(release, pkg, config.policy) : true; // Environment compatibility: compare the selected release's `requires` // constraints against the running host. `requires` is the lexicon's open @@ -578,11 +575,7 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP > {releases.map((r) => { const preRelease = isPreReleaseVersion(r.version); - const policyBlocked = !releasePassesPolicy( - r, - { did: pkg.did, slug }, - config.policy, - ); + const policyBlocked = !releasePassesPolicy(r, pkg, config.policy); return ( diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts index 4a49b77e87..76a513745b 100644 --- a/packages/admin/src/lib/api/registry.ts +++ b/packages/admin/src/lib/api/registry.ts @@ -30,6 +30,7 @@ import type { import { hostEnvFromVersions } from "@emdash-cms/registry-client/env"; import type { HostEnv } from "@emdash-cms/registry-client/env"; import { + isProvenFirstRelease, registryLabelerPolicy, registryLabelerPolicyKey, type RegistryLabelerPolicy, @@ -234,9 +235,9 @@ async function getDiscoveryClient(config: RegistryClientConfig): Promise, policy: RegistryClientConfig["policy"], now: number = Date.now(), ): boolean { @@ -252,6 +256,7 @@ export function releasePassesPolicy( if (releaseExemptFromMinimumAge(policy.minimumReleaseAgeExclude, pkg.did, pkg.slug)) { return true; } + if (isProvenFirstRelease(pkg)) return true; const indexedAt = Date.parse(release.indexedAt); if (!Number.isFinite(indexedAt)) return false; const ageSeconds = (now - indexedAt) / 1000; diff --git a/packages/admin/tests/components/RegistryPluginDetail.test.tsx b/packages/admin/tests/components/RegistryPluginDetail.test.tsx index f8a7b8c5d5..93afc0a120 100644 --- a/packages/admin/tests/components/RegistryPluginDetail.test.tsx +++ b/packages/admin/tests/components/RegistryPluginDetail.test.tsx @@ -72,6 +72,8 @@ interface PkgOverrides { sections?: Record; lastUpdated?: string; labels?: { val?: string; src?: string }[]; + historicalReleaseCount?: number; + releaseHistoryComplete?: boolean; } function makePackage(overrides: PkgOverrides = {}): RegistryPackageView { @@ -80,6 +82,8 @@ function makePackage(overrides: PkgOverrides = {}): RegistryPackageView { handle: "acme.dev", slug: "myplugin", labels: overrides.labels ?? [], + historicalReleaseCount: overrides.historicalReleaseCount, + releaseHistoryComplete: overrides.releaseHistoryComplete, profile: { name: "My Plugin", description: "A short description.", @@ -98,17 +102,20 @@ interface ReleaseOverrides { sbom?: { format?: string; url?: string; checksum?: string }; extensions?: Record; labels?: unknown[]; + indexedAt?: string; + version?: string; } function makeRelease(overrides: ReleaseOverrides = {}): RegistryReleaseView { const cid = `bafyrei${"a".repeat(52)}`; + const version = overrides.version ?? "1.2.3"; return { - uri: "at://did:plc:acme/com.emdashcms.experimental.package.release/myplugin:1.2.3", + uri: `at://did:plc:acme/com.emdashcms.experimental.package.release/myplugin:${version}`, cid, did: "did:plc:acme", package: "myplugin", - version: "1.2.3", - indexedAt: "2025-03-01T00:00:00Z", + version, + indexedAt: overrides.indexedAt ?? "2025-03-01T00:00:00Z", labels: overrides.labels ?? [], release: { sbom: overrides.sbom, @@ -316,6 +323,74 @@ describe("RegistryPluginDetail release withdrawal", () => { }); }); +describe("RegistryPluginDetail minimum release age", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("allows a proven first release through the holdback", async () => { + setup(makePackage({ historicalReleaseCount: 1, releaseHistoryComplete: true }), [ + makeRelease({ indexedAt: new Date().toISOString() }), + ]); + const screen = await render( + + + , + ); + + await expect.element(screen.getByRole("button", { name: "Install" })).toBeEnabled(); + expect(screen.getByText("Release is too new to install").query()).toBeNull(); + }); + + it("keeps incomplete history held back", async () => { + setup(makePackage({ historicalReleaseCount: 1, releaseHistoryComplete: false }), [ + makeRelease({ indexedAt: new Date().toISOString() }), + ]); + const screen = await render( + + + , + ); + + await expect.element(screen.getByRole("button", { name: "Install" })).toBeDisabled(); + await expect.element(screen.getByText("Release is too new to install")).toBeInTheDocument(); + }); + + it("defaults to an older age-qualified release when the newest release is too new", async () => { + setup(makePackage({ historicalReleaseCount: 2, releaseHistoryComplete: true }), [ + makeRelease({ version: "2.0.0", indexedAt: new Date().toISOString() }), + makeRelease({ version: "1.0.0", indexedAt: "2025-03-01T00:00:00Z" }), + ]); + const screen = await render( + + + , + ); + + await expect.element(screen.getByRole("button", { name: "Install" })).toBeEnabled(); + await expect.element(screen.getByText("Version 1.0.0")).toBeInTheDocument(); + expect(screen.getByText("Release is too new to install").query()).toBeNull(); + }); +}); + describe("RegistryPluginDetail independent install consent", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/admin/tests/lib/registry-release-policy.test.ts b/packages/admin/tests/lib/registry-release-policy.test.ts new file mode 100644 index 0000000000..1e4896025c --- /dev/null +++ b/packages/admin/tests/lib/registry-release-policy.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + releasePassesPolicy, + type RegistryPackageView, + type RegistryReleaseView, +} from "../../src/lib/api/registry.js"; + +const NOW = Date.parse("2026-09-12T12:00:00.000Z"); +const policy = { minimumReleaseAgeSeconds: 48 * 60 * 60 }; + +function release(indexedAt: string): RegistryReleaseView { + return { indexedAt } as RegistryReleaseView; +} + +function pkg(history: Partial = {}): RegistryPackageView { + return { + did: "did:plc:publisher", + slug: "gallery", + ...history, + } as RegistryPackageView; +} + +describe("registry minimum release age", () => { + it("exempts a proven first release", () => { + expect( + releasePassesPolicy( + release("2026-09-12T11:59:59.000Z"), + pkg({ historicalReleaseCount: 1, releaseHistoryComplete: true }), + policy, + NOW, + ), + ).toBe(true); + }); + + it.each([ + ["missing evidence", {}], + ["incomplete history", { historicalReleaseCount: 1, releaseHistoryComplete: false }], + ["an established package", { historicalReleaseCount: 2, releaseHistoryComplete: true }], + ])("holds back a new release with %s", (_name, history) => { + expect( + releasePassesPolicy(release("2026-09-12T11:59:59.000Z"), pkg(history), policy, NOW), + ).toBe(false); + }); + + it("accepts an established release at the exact age threshold", () => { + expect( + releasePassesPolicy( + release("2026-09-10T12:00:00.000Z"), + pkg({ historicalReleaseCount: 2, releaseHistoryComplete: true }), + policy, + NOW, + ), + ).toBe(true); + }); + + it("preserves explicit publisher exemptions", () => { + expect( + releasePassesPolicy( + release("2026-09-12T11:59:59.000Z"), + pkg(), + { + ...policy, + minimumReleaseAgeExclude: ["did:plc:publisher"], + }, + NOW, + ), + ).toBe(true); + }); +}); diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index a866660109..5224b497b6 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -13,6 +13,7 @@ import { canonicalizeDeclaredAccess } from "@emdash-cms/plugin-types"; import type { CanonicalDeclaredAccess } from "@emdash-cms/plugin-types"; import { checkEnvCompatibility, findSkippedEnvConstraints } from "@emdash-cms/registry-client/env"; import type { HostEnv } from "@emdash-cms/registry-client/env"; +import { isProvenFirstRelease } from "@emdash-cms/registry-client/listing-policy"; import { evaluateRegistryReleaseWithdrawal } from "@emdash-cms/registry-client/withdrawal"; import { NSID } from "@emdash-cms/registry-lexicons"; import { @@ -732,7 +733,9 @@ export async function handleRegistryInstall( const exclude = registryConfig.policy?.minimumReleaseAgeExclude?.map((e) => e.trim().toLowerCase(), ); - const exempt = releaseExemptFromMinimumAge(exclude, publisherDid, slug); + const exempt = + releaseExemptFromMinimumAge(exclude, publisherDid, slug) || + isProvenFirstRelease(packageView); if (!exempt) { const indexedAt = Date.parse(releaseView.indexedAt); if (!Number.isFinite(indexedAt)) { diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 6700bda14a..f83573ce02 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -56,12 +56,11 @@ export interface RegistryConfig { * Accepts a duration string (`"24h"`, `"48h"`, `"72h"`, `"7d"`) or a * number of seconds. * - * Currently applies uniformly to all releases. A future addition - * may exempt brand-new packages (those with no prior release - * history) so the holdback doesn't block first-time publishing, - * but that exemption is not implemented yet; use - * {@link minimumReleaseAgeExclude} to allowlist trusted publishers - * whose packages should install immediately. + * A package's first release is exempt only when the aggregator + * reports one release and confirms that its retained history is + * complete. Missing or incomplete history keeps the holdback in + * force. Use {@link minimumReleaseAgeExclude} to allowlist trusted + * publishers whose packages should always install immediately. * * Defaults to `undefined` (no holdback). A future trust/moderation * RFC will specify the recommended default. diff --git a/packages/core/tests/unit/api/registry-install-conformance.test.ts b/packages/core/tests/unit/api/registry-install-conformance.test.ts index 7c3dd71ab2..7662fd3e9e 100644 --- a/packages/core/tests/unit/api/registry-install-conformance.test.ts +++ b/packages/core/tests/unit/api/registry-install-conformance.test.ts @@ -157,6 +157,11 @@ function pdsFetch(network: FakePublisherFixture): typeof fetch { async function mockAggregator( fixture: DelegatedReleaseConformanceFixture, context: ConformanceContext, + opts: { + indexedAt?: string; + historicalReleaseCount?: number; + releaseHistoryComplete?: boolean; + } = {}, ): Promise { const direct = new DirectPdsClient({ did: fixture.publisherDid, @@ -173,7 +178,7 @@ async function mockAggregator( did: fixture.publisherDid, package: fixture.packageSlug, version: fixture.version, - indexedAt: "2026-01-01T00:00:00.000Z", + indexedAt: opts.indexedAt ?? "2026-01-01T00:00:00.000Z", labels: [], mirrors: [], release: { @@ -194,6 +199,12 @@ async function mockAggregator( slug: fixture.packageSlug, labels: [], profile: { name: "Aggregator substitution" }, + ...(opts.historicalReleaseCount === undefined + ? {} + : { historicalReleaseCount: opts.historicalReleaseCount }), + ...(opts.releaseHistoryComplete === undefined + ? {} + : { releaseHistoryComplete: opts.releaseHistoryComplete }), }); getLatestRelease.mockResolvedValue(releaseView); listReleases.mockResolvedValue({ releases: [releaseView] }); @@ -334,6 +345,61 @@ describe("registry delegated-release conformance", () => { }, ); + it("exempts a proven first release from the configured holdback", async () => { + const fixture = await createDelegatedReleaseConformanceFixture(); + const context = await createContext(fixture); + await mockAggregator(fixture, context, { + indexedAt: new Date().toISOString(), + historicalReleaseCount: 1, + releaseHistoryComplete: true, + }); + artifactFetch(fixture.artifactBytes); + + const result = await handleRegistryInstall( + db, + storage, + sandbox, + { + ...registryConfig, + policy: { minimumReleaseAge: "48h" }, + }, + { did: fixture.publisherDid, slug: fixture.packageSlug, version: fixture.version }, + { verifyOnly: true, authoritativeRecords: context.options }, + ); + + expect(result).toMatchObject({ success: true }); + }); + + it.each([ + ["missing history evidence", {}], + ["incomplete history", { historicalReleaseCount: 1, releaseHistoryComplete: false }], + ["an earlier release", { historicalReleaseCount: 2, releaseHistoryComplete: true }], + ])("holds back a new release with %s", async (_name, history) => { + const fixture = await createDelegatedReleaseConformanceFixture(); + const context = await createContext(fixture); + await mockAggregator(fixture, context, { + indexedAt: new Date().toISOString(), + ...history, + }); + + const result = await handleRegistryInstall( + db, + storage, + sandbox, + { + ...registryConfig, + policy: { minimumReleaseAge: "48h" }, + }, + { did: fixture.publisherDid, slug: fixture.packageSlug, version: fixture.version }, + { verifyOnly: true, authoritativeRecords: context.options }, + ); + + expect(result).toMatchObject({ + success: false, + error: { code: "RELEASE_TOO_NEW" }, + }); + }); + it("updates with CID-bound re-consent and keeps a concurrent downgrade bundle active", async () => { storage = createMemoryStorage({ deferDeletes: true }); const initial = await createDelegatedReleaseConformanceFixture(); diff --git a/packages/registry-client/src/listing-policy.ts b/packages/registry-client/src/listing-policy.ts index f9d523fff7..624d87e805 100644 --- a/packages/registry-client/src/listing-policy.ts +++ b/packages/registry-client/src/listing-policy.ts @@ -8,6 +8,20 @@ export interface RegistryLabelerPolicy { acceptLabelers?: string; } +export interface ReleaseHistoryEvidence { + historicalReleaseCount?: number; + releaseHistoryComplete?: boolean; +} + +/** Return true only when the aggregator proves one complete observed release history. */ +export function isProvenFirstRelease(evidence: ReleaseHistoryEvidence): boolean { + return ( + evidence.releaseHistoryComplete === true && + Number.isSafeInteger(evidence.historicalReleaseCount) && + evidence.historicalReleaseCount === 1 + ); +} + function normalizeAcceptLabelers(value: string | undefined): string | undefined { const normalized = value?.trim(); return normalized ? normalized : undefined; diff --git a/packages/registry-client/tests/discovery.test.ts b/packages/registry-client/tests/discovery.test.ts index 32560c254e..61fffd603e 100644 --- a/packages/registry-client/tests/discovery.test.ts +++ b/packages/registry-client/tests/discovery.test.ts @@ -1,4 +1,4 @@ -import { ClientResponseError } from "@atcute/client"; +import { ClientResponseError, ClientValidationError } from "@atcute/client"; import { describe, expect, it, vi } from "vitest"; import { @@ -179,6 +179,55 @@ describe("DiscoveryClient", () => { expect(JSON.stringify(result)).not.toContain(unsafeSentinel); }); + it("preserves complete release-history evidence on package views", async () => { + const { fetch } = buildFetchStub({ + "/xrpc/com.emdashcms.experimental.aggregator.getPackage": { + status: 200, + body: { + uri: "at://did:plc:abc/com.emdashcms.experimental.package.profile/gallery", + cid: CID, + did: "did:plc:abc", + slug: "gallery", + indexedAt: "2026-04-01T00:00:00Z", + profile: {}, + historicalReleaseCount: 1, + releaseHistoryComplete: true, + }, + }, + }); + + const client = new DiscoveryClient({ aggregatorUrl: aggregator, fetch }); + await expect(client.getPackage({ did: "did:plc:abc", slug: "gallery" })).resolves.toMatchObject( + { + historicalReleaseCount: 1, + releaseHistoryComplete: true, + }, + ); + }); + + it("rejects malformed release-history evidence", async () => { + const { fetch } = buildFetchStub({ + "/xrpc/com.emdashcms.experimental.aggregator.getPackage": { + status: 200, + body: { + uri: "at://did:plc:abc/com.emdashcms.experimental.package.profile/gallery", + cid: CID, + did: "did:plc:abc", + slug: "gallery", + indexedAt: "2026-04-01T00:00:00Z", + profile: {}, + historicalReleaseCount: "1", + releaseHistoryComplete: true, + }, + }, + }); + + const client = new DiscoveryClient({ aggregatorUrl: aggregator, fetch }); + await expect(client.getPackage({ did: "did:plc:abc", slug: "gallery" })).rejects.toBeInstanceOf( + ClientValidationError, + ); + }); + it("does not downgrade other response failures to ListingUnavailable", async () => { const { fetch } = buildFetchStub({ "/xrpc/com.emdashcms.experimental.aggregator.getPackage": { diff --git a/packages/registry-client/tests/listing-policy.test.ts b/packages/registry-client/tests/listing-policy.test.ts new file mode 100644 index 0000000000..cef04b1ae8 --- /dev/null +++ b/packages/registry-client/tests/listing-policy.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { isProvenFirstRelease } from "../src/listing-policy.js"; + +describe("isProvenFirstRelease", () => { + it("requires one release and explicitly complete history", () => { + expect( + isProvenFirstRelease({ + historicalReleaseCount: 1, + releaseHistoryComplete: true, + }), + ).toBe(true); + }); + + it.each([ + {}, + { historicalReleaseCount: 1 }, + { historicalReleaseCount: 1, releaseHistoryComplete: false }, + { historicalReleaseCount: 0, releaseHistoryComplete: true }, + { historicalReleaseCount: 2, releaseHistoryComplete: true }, + { historicalReleaseCount: 1.5, releaseHistoryComplete: true }, + ])("fails closed for incomplete or malformed evidence %#", (evidence) => { + expect(isProvenFirstRelease(evidence)).toBe(false); + }); +}); diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json index 5696013b7f..d3ecda1aff 100644 --- a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/aggregator/defs.json @@ -48,6 +48,15 @@ "format": "datetime", "description": "When the aggregator first indexed this package." }, + "historicalReleaseCount": { + "type": "integer", + "minimum": 0, + "description": "Number of package release versions retained in the aggregator's history, including releases the publisher later deleted." + }, + "releaseHistoryComplete": { + "type": "boolean", + "description": "Whether the aggregator continuously observed this package from its first live profile creation. When false or absent, clients must not infer that historicalReleaseCount represents the package's complete registry history." + }, "labels": { "type": "array", "description": "Hydrated trusted label state attached by the aggregator's configured policy.", diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts index 79babc8781..522258a9b5 100644 --- a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/aggregator/defs.ts @@ -20,6 +20,11 @@ const _packageViewSchema = /*#__PURE__*/ v.object({ * Publisher's current handle, if known. Best-effort: handles are mutable and may be stale at the moment of read. */ handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.handleString()), + /** + * Number of package release versions retained in the aggregator's history, including releases the publisher later deleted. + * @minimum 0 + */ + historicalReleaseCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), /** * When the aggregator first indexed this package. */ @@ -49,6 +54,10 @@ const _packageViewSchema = /*#__PURE__*/ v.object({ * The signed profile record verbatim, passed through from the publisher's repo (carrying its $type, all required fields, etc.). */ profile: /*#__PURE__*/ v.unknown(), + /** + * Whether the aggregator continuously observed this package from its first live profile creation. When false or absent, clients must not infer that historicalReleaseCount represents the package's complete registry history. + */ + releaseHistoryComplete: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), /** * Package slug (the rkey of the profile record). Denormalised convenience. * @minLength 1