diff --git a/.changeset/shiny-ants-deny.md b/.changeset/shiny-ants-deny.md new file mode 100644 index 000000000..b60827f61 --- /dev/null +++ b/.changeset/shiny-ants-deny.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-moderation": patch +--- + +Adds shared release moderation evaluation for accepted ATProto label streams. diff --git a/package.json b/package.json index 0d62e3cbf..25bdf85f4 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "typecheck:templates": "pnpm run --workspace-concurrency=1 --filter {./templates/*} typecheck", "check": "pnpm run typecheck && pnpm run --filter {./packages/*} check", "test": "pnpm run --filter {./packages/*} test", - "test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-cli --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons test", + "test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-cli --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons --filter @emdash-cms/registry-moderation test", "test:browser": "pnpm run --filter @emdash-cms/admin test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/packages/registry-moderation/package.json b/packages/registry-moderation/package.json new file mode 100644 index 000000000..e02938a20 --- /dev/null +++ b/packages/registry-moderation/package.json @@ -0,0 +1,47 @@ +{ + "name": "@emdash-cms/registry-moderation", + "version": "0.0.0", + "description": "Shared label reduction and release moderation policy for the EmDash plugin registry.", + "type": "module", + "main": "dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "prepublishOnly": "node --run build", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution" + }, + "devDependencies": { + "@arethetypeswrong/cli": "catalog:", + "@types/node": "catalog:", + "publint": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "keywords": [ + "emdash", + "cms", + "plugin-registry", + "atproto", + "moderation" + ], + "author": "Matt Kane", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/emdash-cms/emdash.git", + "directory": "packages/registry-moderation" + }, + "homepage": "https://github.com/emdash-cms/emdash" +} diff --git a/packages/registry-moderation/src/index.ts b/packages/registry-moderation/src/index.ts new file mode 100644 index 000000000..5053a862c --- /dev/null +++ b/packages/registry-moderation/src/index.ts @@ -0,0 +1,395 @@ +export type ModerationLabelValue = + | "assessment-error" + | "assessment-overridden" + | "assessment-passed" + | "assessment-pending" + | "artifact-integrity-failure" + | "broken-release" + | "credential-harvesting" + | "critical-vulnerability" + | "data-exfiltration" + | "impersonation" + | "invalid-bundle" + | "low-quality" + | "malware" + | "misleading-metadata" + | "obfuscated-code" + | "package-disputed" + | "privacy-risk" + | "publisher-compromised" + | "security-yanked" + | "supply-chain-compromise" + | "suspicious-code" + | "undeclared-access" + | "!takedown"; + +/** The ATProto label fields used to reduce a signed label stream. */ +export interface ModerationLabel { + ver: 1; + src: string; + uri: string; + val: ModerationLabelValue | (string & {}); + cts: string; + cid?: string; + neg?: boolean; + exp?: string; +} + +export interface AcceptedLabelerPolicy { + did: string; + redact: boolean; +} + +export interface ReleaseSubjectContext { + publisherDid: string; + package: { + uri: string; + cid: string; + }; + release: { + uri: string; + cid: string; + }; +} + +export interface EvaluateReleaseModerationInput { + acceptedLabelers: AcceptedLabelerPolicy[]; + context: ReleaseSubjectContext; + evaluatedAt: Date | string; + labels: ModerationLabel[]; +} + +export type ReleaseEligibility = "eligible" | "pending" | "error" | "blocked"; + +export interface ReleaseModeration { + eligibility: ReleaseEligibility; + reasonCodes: string[]; + blockingLabels: string[]; + stateLabels: string[]; + warningLabels: string[]; + suppressedLabels: string[]; + applicableLabels: ModerationLabel[]; + redacted: boolean; +} + +const AUTOMATED_BLOCKS = new Set([ + "malware", + "data-exfiltration", + "credential-harvesting", + "supply-chain-compromise", + "critical-vulnerability", + "artifact-integrity-failure", + "invalid-bundle", + "undeclared-access", + "impersonation", +]); + +const WARNINGS = new Set([ + "suspicious-code", + "obfuscated-code", + "privacy-risk", + "misleading-metadata", + "low-quality", + "broken-release", + "package-disputed", +]); + +const RELEASE_VALUES = new Set([ + "assessment-error", + "assessment-overridden", + "assessment-passed", + "assessment-pending", + "security-yanked", + "!takedown", + ...AUTOMATED_BLOCKS, + ...WARNINGS, +]); + +interface ParsedInstant { + seconds: bigint; + fraction: string; +} + +interface LabelReduction { + active: ModerationLabel[]; + collisions: ModerationLabel[][]; +} + +const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/; + +function daysFromCivil(year: bigint, month: bigint, day: bigint): bigint { + const adjustedYear = year - (month <= 2n ? 1n : 0n); + const era = (adjustedYear >= 0n ? adjustedYear : adjustedYear - 399n) / 400n; + const yearOfEra = adjustedYear - era * 400n; + const shiftedMonth = month + (month > 2n ? -3n : 9n); + const dayOfYear = (153n * shiftedMonth + 2n) / 5n + day - 1n; + const dayOfEra = yearOfEra * 365n + yearOfEra / 4n - yearOfEra / 100n + dayOfYear; + return era * 146_097n + dayOfEra - 719_468n; +} + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function parseInstant(value: Date | string, field: string): ParsedInstant { + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) + throw new TypeError(`${field} must be a valid RFC 3339 timestamp`); + return { + seconds: BigInt(Math.floor(value.getTime() / 1000)), + fraction: `${value.getMilliseconds()}`.padStart(3, "0"), + }; + } + const match = RFC3339.exec(value); + if (!match) throw new TypeError(`${field} must be a valid RFC 3339 timestamp`); + const [, yearText, monthText, dayText, hourText, minuteText, secondText, fraction = "", zone] = + match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + const zoneText = zone!; + const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if ( + year === 0 || + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth[month - 1]! || + hour > 23 || + minute > 59 || + second > 59 + ) { + throw new TypeError(`${field} must be a valid RFC 3339 timestamp`); + } + let offsetSeconds = 0n; + if (zoneText !== "Z") { + const offsetHour = Number(zoneText.slice(1, 3)); + const offsetMinute = Number(zoneText.slice(4, 6)); + if ( + (offsetHour === 0 && offsetMinute === 0 && zoneText[0] === "-") || + offsetHour > 23 || + offsetMinute > 59 + ) + throw new TypeError(`${field} must be a valid RFC 3339 timestamp`); + offsetSeconds = + BigInt(offsetHour * 3600 + offsetMinute * 60) * (zoneText[0] === "+" ? 1n : -1n); + } + return { + seconds: + daysFromCivil(BigInt(year), BigInt(month), BigInt(day)) * 86_400n + + BigInt(hour * 3600 + minute * 60 + second) - + offsetSeconds, + fraction, + }; +} + +function compareInstants(left: ParsedInstant, right: ParsedInstant): number { + if (left.seconds !== right.seconds) return left.seconds < right.seconds ? -1 : 1; + const length = Math.max(left.fraction.length, right.fraction.length); + for (let index = 0; index < length; index++) { + const leftDigit = left.fraction[index] ?? "0"; + const rightDigit = right.fraction[index] ?? "0"; + if (leftDigit !== rightDigit) return leftDigit < rightDigit ? -1 : 1; + } + return 0; +} + +function streamKey(label: ModerationLabel): string { + return `${label.src}\u0000${label.uri}\u0000${label.val}`; +} + +function isSameEvent(left: ModerationLabel, right: ModerationLabel): boolean { + return ( + left.ver === right.ver && + left.src === right.src && + left.uri === right.uri && + left.cid === right.cid && + left.val === right.val && + (left.neg === true) === (right.neg === true) && + left.cts === right.cts && + left.exp === right.exp + ); +} + +/** + * Reduces each `(src, uri, val)` label stream to its current winner. CID is + * deliberately excluded from the key so a CID-bearing negation replaces it. + */ +function reduceLabels(labels: ModerationLabel[], evaluatedAt: Date | string): LabelReduction { + const now = parseInstant(evaluatedAt, "evaluatedAt"); + const streams = new Map< + string, + { label: ModerationLabel; cts: ParsedInstant; exp?: ParsedInstant }[] + >(); + + for (const label of labels) { + const entry = { + label, + cts: parseInstant(label.cts, "label.cts"), + exp: label.exp === undefined ? undefined : parseInstant(label.exp, "label.exp"), + }; + const key = streamKey(label); + const entries = streams.get(key); + if (entries) entries.push(entry); + else streams.set(key, [entry]); + } + + const active: ModerationLabel[] = []; + const collisions: ModerationLabel[][] = []; + for (const entries of streams.values()) { + const first = entries[0]; + if (!first) continue; + const winners = entries.filter((entry) => + entries.every((other) => compareInstants(entry.cts, other.cts) >= 0), + ); + const winner = winners[0]; + if (!winner) continue; + + if (winners.some((entry) => !isSameEvent(winner.label, entry.label))) { + collisions.push(winners.map((entry) => entry.label)); + continue; + } + if ( + winner.label.neg === true || + (winner.exp !== undefined && compareInstants(winner.exp, now) <= 0) + ) { + continue; + } + active.push(winner.label); + } + + return { active, collisions }; +} + +function appliesToContext(label: ModerationLabel, context: ReleaseSubjectContext): boolean { + if (label.uri === context.release.uri) { + if (!RELEASE_VALUES.has(label.val)) return false; + if (label.cid !== undefined) return label.cid === context.release.cid; + return label.val === "security-yanked" || label.val === "!takedown"; + } + if (label.uri === context.package.uri) { + if (label.val !== "!takedown" && label.val !== "package-disputed") return false; + if (label.cid !== undefined) return label.cid === context.package.cid; + return true; + } + return ( + label.uri === context.publisherDid && + label.cid === undefined && + (label.val === "!takedown" || label.val === "publisher-compromised") + ); +} + +function collisionAppliesToContext( + labels: ModerationLabel[], + context: ReleaseSubjectContext, +): boolean { + return labels.some((label) => appliesToContext(label, context)); +} + +function orderedValues(labels: ModerationLabel[]): string[] { + const values: string[] = []; + new Set(labels.map((label) => label.val)).forEach((value) => values.push(value)); + return values.toSorted(); +} + +/** Evaluates accepted, current label state for one exact package release. */ +export function evaluateReleaseModeration( + input: EvaluateReleaseModerationInput, +): ReleaseModeration { + const policies = new Map(); + for (const policy of input.acceptedLabelers) { + const existing = policies.get(policy.did); + policies.set(policy.did, { + did: policy.did, + redact: existing?.redact === true || policy.redact, + }); + } + const unacceptedLabelsIgnored = input.labels.some((label) => !policies.has(label.src)); + const reduction = reduceLabels( + input.labels.filter((label) => policies.has(label.src)), + input.evaluatedAt, + ); + const applicableLabels = reduction.active + .filter((label) => appliesToContext(label, input.context)) + .toSorted((left, right) => streamKey(left).localeCompare(streamKey(right))); + const collisions = reduction.collisions.filter((labels) => + collisionAppliesToContext(labels, input.context), + ); + + const manualBlocks = applicableLabels.filter( + (label) => + label.val === "!takedown" || + label.val === "security-yanked" || + label.val === "publisher-compromised", + ); + const warnings = applicableLabels.filter((label) => WARNINGS.has(label.val)); + const suppressed: ModerationLabel[] = []; + const unsuppressedStates: ModerationLabel[] = []; + const unsuppressedBlocks: ModerationLabel[] = []; + const passSources = new Set(); + const overrideSources = new Set(); + + for (const [source] of policies) { + const sourceLabels = applicableLabels.filter((label) => label.src === source); + const hasPass = sourceLabels.some((label) => label.val === "assessment-passed"); + const hasOverride = sourceLabels.some((label) => label.val === "assessment-overridden"); + const override = hasPass && hasOverride; + if (override) overrideSources.add(source); + else if (hasPass) passSources.add(source); + + for (const label of sourceLabels) { + if (label.val === "assessment-pending" || label.val === "assessment-error") { + if (override) suppressed.push(label); + else unsuppressedStates.push(label); + } else if (AUTOMATED_BLOCKS.has(label.val)) { + if (override) suppressed.push(label); + else unsuppressedBlocks.push(label); + } + } + } + + const reasonCodes: string[] = []; + let eligibility: ReleaseEligibility; + if (manualBlocks.length > 0) { + eligibility = "blocked"; + reasonCodes.push("manual-block"); + } else if (collisions.length > 0) { + eligibility = "error"; + reasonCodes.push("label-state-collision"); + } else if (unsuppressedStates.some((label) => label.val === "assessment-error")) { + eligibility = "error"; + reasonCodes.push("assessment-error"); + } else if (unsuppressedStates.some((label) => label.val === "assessment-pending")) { + eligibility = "pending"; + reasonCodes.push("assessment-pending"); + } else if (unsuppressedBlocks.length > 0) { + eligibility = "blocked"; + reasonCodes.push("automated-block"); + } else if (passSources.size === 0 && overrideSources.size === 0) { + eligibility = "blocked"; + reasonCodes.push("missing-assessment-pass"); + } else { + eligibility = "eligible"; + reasonCodes.push( + overrideSources.size > 0 ? "eligible-manual-override" : "eligible-assessment-pass", + ); + if (warnings.length > 0) reasonCodes.push("warning-labels"); + } + if (unacceptedLabelsIgnored) reasonCodes.push("unaccepted-labels-ignored"); + + return { + eligibility, + reasonCodes, + blockingLabels: orderedValues([...manualBlocks, ...unsuppressedBlocks]), + stateLabels: orderedValues(unsuppressedStates), + warningLabels: orderedValues(warnings), + suppressedLabels: orderedValues(suppressed), + applicableLabels, + redacted: applicableLabels.some( + (label) => label.val === "!takedown" && policies.get(label.src)?.redact === true, + ), + }; +} diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-cases.json b/packages/registry-moderation/tests/fixtures/moderation-cases.json similarity index 100% rename from .opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-cases.json rename to packages/registry-moderation/tests/fixtures/moderation-cases.json diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-policy.json b/packages/registry-moderation/tests/fixtures/moderation-policy.json similarity index 100% rename from .opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/moderation-policy.json rename to packages/registry-moderation/tests/fixtures/moderation-policy.json diff --git a/packages/registry-moderation/tests/moderation.test.ts b/packages/registry-moderation/tests/moderation.test.ts new file mode 100644 index 000000000..7474f0ea9 --- /dev/null +++ b/packages/registry-moderation/tests/moderation.test.ts @@ -0,0 +1,364 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { evaluateReleaseModeration, type ModerationLabel } from "../src/index.js"; + +const source = "did:example:trusted"; +const otherSource = "did:example:other"; +const context = { + publisherDid: "did:example:publisher", + package: { uri: "at://did:example:publisher/com.example.package/profile", cid: "package-cid" }, + release: { uri: "at://did:example:publisher/com.example.release/1", cid: "release-cid" }, +}; + +function label( + overrides: Partial & Pick, +): ModerationLabel { + return { + ver: 1, + src: source, + uri: context.release.uri, + cid: context.release.cid, + cts: "2026-07-10T12:00:00.000Z", + ...overrides, + }; +} + +function evaluate(labels: ModerationLabel[], redact = false) { + return evaluateReleaseModeration({ + acceptedLabelers: [{ did: source, redact }], + context, + evaluatedAt: "2026-07-10T13:00:00.000Z", + labels, + }); +} + +describe("release moderation", () => { + it("accepts an exact-CID assessment pass", () => { + expect(evaluate([label({ val: "assessment-passed" })])).toMatchObject({ + eligibility: "eligible", + reasonCodes: ["eligible-assessment-pass"], + }); + }); + + it("blocks when no accepted source has a pass", () => { + expect(evaluate([])).toMatchObject({ + eligibility: "blocked", + reasonCodes: ["missing-assessment-pass"], + }); + }); + + it("uses a current negation rather than an older pass", () => { + const pass = label({ val: "assessment-passed" }); + const negation = label({ + val: "assessment-passed", + neg: true, + cts: "2026-07-10T12:01:00.000Z", + }); + expect(evaluate([pass, negation]).eligibility).toBe("blocked"); + }); + + it("does not revive an older pass when the current winner expires", () => { + const pass = label({ val: "assessment-passed" }); + const expired = label({ + val: "assessment-passed", + cts: "2026-07-10T12:01:00.000Z", + exp: "2026-07-10T12:30:00.000Z", + }); + expect(evaluate([pass, expired]).eligibility).toBe("blocked"); + }); + + it("fails closed on non-identical equal-time stream events", () => { + const pass = label({ val: "assessment-passed" }); + const collision = label({ val: "assessment-passed", cid: "different-cid" }); + expect(evaluate([pass, collision])).toMatchObject({ + eligibility: "error", + reasonCodes: ["label-state-collision"], + }); + }); + + it("does not let a collision for unrelated CIDs block the current release", () => { + const first = label({ val: "assessment-passed", cid: "old-release-cid" }); + const second = label({ val: "assessment-passed", cid: "another-old-release-cid" }); + expect(evaluate([first, second])).toMatchObject({ + eligibility: "blocked", + reasonCodes: ["missing-assessment-pass"], + }); + }); + + it("does not apply a release label for another CID", () => { + expect( + evaluate([label({ val: "assessment-passed", cid: "old-release-cid" })]).eligibility, + ).toBe("blocked"); + }); + + it("cascades URI-wide package and publisher actions", () => { + const packageTakedown = label({ val: "!takedown", uri: context.package.uri, cid: undefined }); + const publisherBlock = label({ + val: "publisher-compromised", + uri: context.publisherDid, + cid: undefined, + }); + expect(evaluate([label({ val: "assessment-passed" }), packageTakedown]).eligibility).toBe( + "blocked", + ); + expect(evaluate([label({ val: "assessment-passed" }), publisherBlock]).eligibility).toBe( + "blocked", + ); + }); + + it("does not apply CID-bound manual actions to a different revision", () => { + const releaseTakedown = label({ val: "!takedown", cid: "old-release-cid" }); + const packageYank = label({ + val: "!takedown", + uri: context.package.uri, + cid: "old-package-cid", + }); + expect(evaluate([label({ val: "assessment-passed" }), releaseTakedown]).eligibility).toBe( + "eligible", + ); + expect(evaluate([label({ val: "assessment-passed" }), packageYank]).eligibility).toBe( + "eligible", + ); + }); + + it("keeps warning-only releases eligible", () => { + expect( + evaluate([label({ val: "assessment-passed" }), label({ val: "suspicious-code" })]), + ).toMatchObject({ + eligibility: "eligible", + warningLabels: ["suspicious-code"], + }); + }); + + it("suppresses only its source's automated findings with a valid override", () => { + const result = evaluate([ + label({ val: "assessment-passed" }), + label({ val: "assessment-overridden" }), + label({ val: "assessment-pending" }), + label({ val: "malware" }), + ]); + expect(result).toMatchObject({ + eligibility: "eligible", + suppressedLabels: ["assessment-pending", "malware"], + }); + }); + + it("does not let an override bypass a broader manual block", () => { + const publisherBlock = label({ + val: "publisher-compromised", + uri: context.publisherDid, + cid: undefined, + }); + expect( + evaluate([ + label({ val: "assessment-passed" }), + label({ val: "assessment-overridden" }), + publisherBlock, + ]), + ).toMatchObject({ + eligibility: "blocked", + reasonCodes: ["manual-block"], + }); + }); + + it("aggregates another accepted source's error, pending, and block over a pass", () => { + const acceptedLabelers = [ + { did: source, redact: false }, + { did: otherSource, redact: false }, + ]; + for (const value of ["assessment-error", "assessment-pending", "malware"] as const) { + const result = evaluateReleaseModeration({ + acceptedLabelers, + context, + evaluatedAt: "2026-07-10T13:00:00.000Z", + labels: [label({ val: "assessment-passed" }), label({ val: value, src: otherSource })], + }); + expect(result.eligibility).toBe( + value === "assessment-error" + ? "error" + : value === "assessment-pending" + ? "pending" + : "blocked", + ); + } + }); + + it("ignores an unaccepted source", () => { + const result = evaluate([label({ val: "assessment-passed", src: otherSource })]); + expect(result).toMatchObject({ + eligibility: "blocked", + reasonCodes: ["missing-assessment-pass", "unaccepted-labels-ignored"], + }); + }); + + it("does not parse malformed labels from unaccepted sources", () => { + const result = evaluate([ + label({ val: "assessment-passed" }), + label({ val: "malware", src: otherSource, cts: "not-a-datetime" }), + ]); + expect(result).toMatchObject({ + eligibility: "eligible", + reasonCodes: ["eligible-assessment-pass", "unaccepted-labels-ignored"], + }); + }); + + it("ignores unknown label values, including a colliding stream", () => { + const unknown = label({ val: "future-label" }); + const collision = label({ val: "future-label", cid: "other-cid" }); + expect(evaluate([label({ val: "assessment-passed" }), unknown, collision])).toMatchObject({ + eligibility: "eligible", + reasonCodes: ["eligible-assessment-pass"], + }); + }); + + it("always blocks accepted takedowns while redact controls only presentation", () => { + const takedown = label({ val: "!takedown", cid: undefined }); + expect(evaluate([label({ val: "assessment-passed" }), takedown], false)).toMatchObject({ + eligibility: "blocked", + redacted: false, + }); + expect(evaluate([label({ val: "assessment-passed" }), takedown], true)).toMatchObject({ + eligibility: "blocked", + redacted: true, + }); + }); + + it("ORs redact flags for duplicate accepted labeler policies", () => { + const result = evaluateReleaseModeration({ + acceptedLabelers: [ + { did: source, redact: false }, + { did: source, redact: true }, + ], + context, + evaluatedAt: "2026-07-10T13:00:00.000Z", + labels: [label({ val: "!takedown", cid: undefined })], + }); + expect(result).toMatchObject({ eligibility: "blocked", redacted: true }); + }); + + it("treats explicit false negation as an omitted negation", () => { + const pass = label({ val: "assessment-passed" }); + const currentPass = label({ + val: "assessment-passed", + cid: "release-cid", + neg: false, + }); + expect(evaluate([pass, currentPass]).eligibility).toBe("eligible"); + }); + + it("orders arbitrary fractional timestamps without truncating milliseconds", () => { + const oldPass = label({ val: "assessment-passed", cts: "2026-07-10T12:00:00.1234Z" }); + const pending = label({ + val: "assessment-passed", + neg: true, + cts: "2026-07-10T12:00:00.1235Z", + }); + expect(evaluate([oldPass, pending]).eligibility).toBe("blocked"); + }); + + it("rejects invalid RFC 3339 calendar and timestamp syntax", () => { + expect(() => + evaluate([label({ val: "assessment-passed", cts: "2026-02-30T12:00:00Z" })]), + ).toThrow(TypeError); + expect(() => + evaluate([label({ val: "assessment-passed", cts: "2026-07-10 12:00:00Z" })]), + ).toThrow(TypeError); + expect(() => + evaluate([label({ val: "assessment-passed", cts: "2026-07-10T12:00:00-00:00" })]), + ).toThrow(TypeError); + expect(() => + evaluate([label({ val: "assessment-passed", cts: "0000-01-01T00:00:00Z" })]), + ).toThrow(TypeError); + expect(() => + evaluate([label({ val: "assessment-passed", cts: "0000-01-01T00:00:00+01:00" })]), + ).toThrow(TypeError); + }); +}); + +interface FixtureLabel { + src: string; + subject: "release" | "package" | "publisher"; + cid?: string; + val: string; + cts: string; + neg?: boolean; + exp?: string; +} + +interface FixtureCase { + id: string; + acceptedLabellers?: { src: string; redact: boolean }[]; + subject?: Record; + labels?: FixtureLabel[]; + expected: { + eligibility: string; + reasonCodes: string[]; + blockingLabels: string[]; + stateLabels: string[]; + warningLabels: string[]; + suppressedLabels: string[]; + redacted: boolean; + }; +} + +const corpus = JSON.parse( + readFileSync(new URL("./fixtures/moderation-cases.json", import.meta.url), "utf8"), +) as { + evaluatedAt: string; + sources: Record; + caseDefaults: Required>; + cases: FixtureCase[]; +}; + +function expectedValues(references: string[]): string[] { + return references.map((reference) => reference.slice(reference.lastIndexOf(":") + 1)).toSorted(); +} + +describe("ratified moderation corpus", () => { + for (const fixtureCase of corpus.cases) { + it(fixtureCase.id, () => { + const subject = { ...corpus.caseDefaults.subject, ...fixtureCase.subject }; + const labels = fixtureCase.labels ?? corpus.caseDefaults.labels; + const result = evaluateReleaseModeration({ + acceptedLabelers: ( + fixtureCase.acceptedLabellers ?? corpus.caseDefaults.acceptedLabellers + ).map((policy) => ({ + did: corpus.sources[policy.src]!, + redact: policy.redact, + })), + context: { + publisherDid: subject.publisherDid!, + package: { uri: subject.packageUri!, cid: subject.packageCid! }, + release: { uri: subject.releaseUri!, cid: subject.releaseCid! }, + }, + evaluatedAt: corpus.evaluatedAt, + labels: labels.map((fixtureLabel) => ({ + ver: 1, + src: corpus.sources[fixtureLabel.src]!, + uri: + fixtureLabel.subject === "publisher" + ? subject.publisherDid! + : fixtureLabel.subject === "package" + ? subject.packageUri! + : subject.releaseUri!, + cid: fixtureLabel.cid, + val: fixtureLabel.val, + cts: fixtureLabel.cts, + neg: fixtureLabel.neg, + exp: fixtureLabel.exp, + })), + }); + expect(result).toMatchObject({ + eligibility: fixtureCase.expected.eligibility, + reasonCodes: fixtureCase.expected.reasonCodes, + blockingLabels: expectedValues(fixtureCase.expected.blockingLabels), + stateLabels: expectedValues(fixtureCase.expected.stateLabels), + warningLabels: expectedValues(fixtureCase.expected.warningLabels), + suppressedLabels: expectedValues(fixtureCase.expected.suppressedLabels), + redacted: fixtureCase.expected.redacted, + }); + }); + } +}); diff --git a/packages/registry-moderation/tsconfig.json b/packages/registry-moderation/tsconfig.json new file mode 100644 index 000000000..ae7d1ab8c --- /dev/null +++ b/packages/registry-moderation/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/registry-moderation/tsdown.config.ts b/packages/registry-moderation/tsdown.config.ts new file mode 100644 index 000000000..246ca011f --- /dev/null +++ b/packages/registry-moderation/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + outExtensions: () => ({ js: ".js" }), + dts: true, + clean: true, + platform: "neutral", + target: "es2024", +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af45cfb8a..3187c6ce5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2232,6 +2232,27 @@ importers: specifier: 'catalog:' version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + packages/registry-moderation: + devDependencies: + '@arethetypeswrong/cli': + specifier: 'catalog:' + version: 0.18.2 + '@types/node': + specifier: 'catalog:' + version: 24.10.13 + publint: + specifier: 'catalog:' + version: 0.3.17 + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(publint@0.3.17)(typescript@6.0.3) + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + packages/workerd: dependencies: emdash: @@ -2651,6 +2672,7 @@ packages: '@arethetypeswrong/cli@0.18.2': resolution: {integrity: sha512-PcFM20JNlevEDKBg4Re29Rtv2xvjvQZzg7ENnrWFSS0PHgdP2njibVFw+dRUhNkPgNfac9iUqO0ohAXqQL4hbw==} engines: {node: '>=20'} + hasBin: true '@arethetypeswrong/core@0.18.2': resolution: {integrity: sha512-GiwTmBFOU1/+UVNqqCGzFJYfBXEytUkiI+iRZ6Qx7KmUVtLm00sYySkfe203C9QtPG11yOz1ZaMek8dT/xnlgg==} @@ -10324,6 +10346,7 @@ packages: publint@0.3.17: resolution: {integrity: sha512-Q3NLegA9XM6usW+dYQRG1g9uEHiYUzcCVBJDJ7yMcWRqVU9LYZUWdqbwMZfmTCFC5PZLQpLAmhvRcQRl3exqkw==} engines: {node: '>=18'} + hasBin: true pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -11125,6 +11148,7 @@ packages: typescript@5.6.1-rc: resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} engines: {node: '>=14.17'} + hasBin: true typescript@6.0.0-beta: resolution: {integrity: sha512-CldZdztDpQRLM1HC6WDQjQkQN5Ub5zRau737a1diGh3lPmb9oRsaWHk1y5iqK0o7+1bNJ0oXfEGRkAogFZBL+Q==} @@ -11133,6 +11157,7 @@ packages: typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} + hasBin: true uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}