Skip to content
5 changes: 5 additions & 0 deletions .changeset/discovery-empty-accept-labelers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/registry-client": patch
---

Sends an explicitly empty `atproto-accept-labelers` header when `DiscoveryClient` is configured with `acceptLabelers: ""`, so "accept no labelers" is honored instead of being dropped and letting the aggregator apply its trusted defaults. Omitting the option entirely still sends no header.
5 changes: 5 additions & 0 deletions .changeset/enforce-image-moderation-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/registry-moderation": minor
---

Blocks releases labeled `hateful-imagery`, `explicit-imagery`, or `graphic-violence` -- these automated-block labels were issuable by the labeler but silently ignored by release evaluation and enforcement, leaving a policy-blocked release installable. Also recognizes the `content-warning` label as a non-blocking warning, and exposes the `AUTOMATED_BLOCKS` and `WARNINGS` label-value sets for consumers that classify labels directly.
104 changes: 100 additions & 4 deletions apps/aggregator/src/routes/xrpc/label-enforcement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import { NSID } from "@emdash-cms/registry-lexicons";
import {
AUTOMATED_BLOCKS,
PACKAGE_SCOPE_BLOCK_VALUES,
RELEASE_BLOCK_VALUES,
type AcceptedLabelerPolicy,
Expand All @@ -32,6 +33,41 @@ const HYDRATION_CHUNK_SIZE = 50;
* hydration results — redaction decisions must see the full label set. */
export const LABELS_MAX_LENGTH = 64;

/** Release-scope automated-block values (`RELEASE_BLOCK_VALUES` minus the
* manual `security-yanked` / `!takedown`). The override exception below
* applies only to these — the manual values are never suppressed. */
const AUTOMATED_BLOCK_VALUES: readonly string[] = [...AUTOMATED_BLOCKS];
/** The manual release blocks the reviewer override pair never suppresses. */
const MANUAL_RELEASE_BLOCK_VALUES: readonly string[] = RELEASE_BLOCK_VALUES.filter(
(value) => !AUTOMATED_BLOCKS.has(value),
);
/** Assessment-state label values: `assessment-pending` / `assessment-error`
* steer eligibility, and `assessment-passed` / `assessment-overridden` form
* the reviewer override pair. Enforcement-relevant for view truncation. */
const ASSESSMENT_STATE_VALUES: readonly string[] = [
"assessment-pending",
"assessment-passed",
"assessment-overridden",
"assessment-error",
];

const ENFORCEMENT_HARD_BLOCK_VALUES: ReadonlySet<string> = new Set([
...RELEASE_BLOCK_VALUES,
...PACKAGE_SCOPE_BLOCK_VALUES,
]);
const ENFORCEMENT_STATE_VALUES: ReadonlySet<string> = new Set(ASSESSMENT_STATE_VALUES);

/** Truncation priority for a view's `labels`: hard blocks (0) rank ahead of
* assessment states (1) ahead of informational labels (2). Blocks decide a
* client's install/serve refusal, so the lexicon `maxLength` cap must never
* drop one in favour of a display-only label; states change eligibility and
* complete the override pair. Used by `capLabels`. */
export function labelTruncationPriority(val: string): 0 | 1 | 2 {
if (ENFORCEMENT_HARD_BLOCK_VALUES.has(val)) return 0;
if (ENFORCEMENT_STATE_VALUES.has(val)) return 1;
return 2;
}

export interface EnforcementSql {
sql: string;
bindings: unknown[];
Expand Down Expand Up @@ -88,6 +124,7 @@ export function buildPackageEnforcementSql(
alias = "p.",
): EnforcementSql {
if (accepted.length === 0) return { sql: "", bindings: [] };
assertAlias(alias);
const srcs = accepted.map((policy) => policy.did);
const sql = `
AND NOT EXISTS (
Expand All @@ -111,12 +148,33 @@ export interface ReleaseEnforcementAliases {
package?: string;
}

/** A SQL row alias with its trailing dot (e.g. `"r."`). */
const ALIAS_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*\.$/;

/** Guards a caller-supplied alias before it is interpolated into raw SQL.
* Callers pass compile-time constants today, but the builders are exported. */
function assertAlias(alias: string): void {
if (!ALIAS_PATTERN.test(alias))
throw new TypeError("SQL alias must be an identifier followed by '.'");
}

/**
* `NOT EXISTS` clause excluding a release whose own URI carries a
* `RELEASE_BLOCK_VALUES` label, or whose parent package URI / publisher DID
* carries a `PACKAGE_SCOPE_BLOCK_VALUES` label (cascade), from an accepted
* source. Requires the `packages` row joined under `aliases.package` — the
* package-scope branch's CID comparison reads its `signature_metadata`.
*
* Mirrors the hydrated evaluator's §10 override rule (see
* `evaluateReleaseModerationCore` in `@emdash-cms/registry-moderation`): a
* source's exact-CID `assessment-passed` + `assessment-overridden` pair
* suppresses that same source's automated blocks for the release. Without
* this, a post-override re-assessment that re-issues a live automated block
* would keep the release out of latest-selection even though the evaluator
* treats it as eligible. The exception is scoped exactly as the evaluator
* scopes it: automated blocks on the release URI only — the manual
* `security-yanked` / `!takedown` release blocks and the package/publisher
* cascade are never suppressed by the pair.
*/
export function buildReleaseEnforcementSql(
accepted: AcceptedLabelerPolicy[],
Expand All @@ -126,7 +184,11 @@ export function buildReleaseEnforcementSql(
if (accepted.length === 0) return { sql: "", bindings: [] };
const releaseAlias = aliases.release ?? "r.";
const packageAlias = aliases.package ?? "p.";
assertAlias(releaseAlias);
assertAlias(packageAlias);
const srcs = accepted.map((policy) => policy.did);
const releaseUriExpr = `'at://' || ${releaseAlias}did || '/${NSID.packageRelease}/' || ${releaseAlias}rkey`;
const releaseCidExpr = `json_extract(${releaseAlias}signature_metadata, '$.cid')`;
const sql = `
AND NOT EXISTS (
SELECT 1 FROM label_state ls
Expand All @@ -135,9 +197,35 @@ export function buildReleaseEnforcementSql(
AND (ls.exp_epoch_ms IS NULL OR ls.exp_epoch_ms > ?)
AND (
(
ls.uri = 'at://' || ${releaseAlias}did || '/${NSID.packageRelease}/' || ${releaseAlias}rkey
AND ls.val IN (${inClause(RELEASE_BLOCK_VALUES)})
AND (ls.cid IS NULL OR ls.cid = json_extract(${releaseAlias}signature_metadata, '$.cid'))
ls.uri = ${releaseUriExpr}
AND ls.val IN (${inClause(AUTOMATED_BLOCK_VALUES)})
AND (ls.cid IS NULL OR ls.cid = ${releaseCidExpr})
AND NOT (
EXISTS (
SELECT 1 FROM label_state pass
WHERE pass.src = ls.src
AND pass.uri = ${releaseUriExpr}
AND pass.val = 'assessment-passed'
AND pass.cid = ${releaseCidExpr}
AND pass.neg = 0
AND (pass.exp_epoch_ms IS NULL OR pass.exp_epoch_ms > ?)
)
AND EXISTS (
SELECT 1 FROM label_state ovr
WHERE ovr.src = ls.src
AND ovr.uri = ${releaseUriExpr}
AND ovr.val = 'assessment-overridden'
AND ovr.cid = ${releaseCidExpr}
AND ovr.neg = 0
AND (ovr.exp_epoch_ms IS NULL OR ovr.exp_epoch_ms > ?)
)
)
)
OR
(
ls.uri = ${releaseUriExpr}
AND ls.val IN (${inClause(MANUAL_RELEASE_BLOCK_VALUES)})
AND (ls.cid IS NULL OR ls.cid = ${releaseCidExpr})
)
OR
(
Expand All @@ -150,7 +238,15 @@ export function buildReleaseEnforcementSql(
`;
return {
sql,
bindings: [...srcs, nowMs, ...RELEASE_BLOCK_VALUES, ...PACKAGE_SCOPE_BLOCK_VALUES],
bindings: [
...srcs,
nowMs,
...AUTOMATED_BLOCK_VALUES,
nowMs,
nowMs,
...MANUAL_RELEASE_BLOCK_VALUES,
...PACKAGE_SCOPE_BLOCK_VALUES,
],
};
}

Expand Down
43 changes: 35 additions & 8 deletions apps/aggregator/src/routes/xrpc/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import { type AggregatorDefs, NSID } from "@emdash-cms/registry-lexicons";

import { isPlainObject, parseSignatureMetadataCid } from "../../utils.js";
import { LABELS_MAX_LENGTH, type LabelView } from "./label-enforcement.js";
import { LABELS_MAX_LENGTH, labelTruncationPriority, type LabelView } from "./label-enforcement.js";

/** Subset of columns from `packages` we read for `packageView`. Selecting
* exactly these columns keeps the SQL query auditable and cheap. */
Expand Down Expand Up @@ -166,15 +166,42 @@ export function publisherUri(row: Pick<PublisherRow, "did">) {
* labels are the union of multiple hydrated subjects (e.g. a release's own
* URI plus its parent package and publisher DID). Hydration returns
* untruncated per-subject sets so redaction decisions see every label;
* this boundary cap trims only the final view array. */
* this boundary cap trims only the final view array.
*
* The trim is enforcement-preserving: a plain slice can drop the only hard
* block when a subject carries more than `LABELS_MAX_LENGTH` labels, and a
* client evaluating the truncated view would then treat the release as
* installable. Labels are ordered by `labelTruncationPriority` (blocks, then
* assessment states, then informational) before slicing, so blocks survive.
* Hydrated labels never carry negations (`hydrateLabels` returns only
* `neg = 0` rows), so ordering can't split a label from its negation. In the
* pathological case of more than `LABELS_MAX_LENGTH` hard blocks the kept
* slice is still all blocks — the release stays blocked, only the exact set
* is lossy — and we log an error. */
function capLabels(labels: LabelView[], uri: string): LabelView[] {
if (labels.length <= LABELS_MAX_LENGTH) return labels;
console.warn("[aggregator] view labels truncated to maxLength", {
uri,
count: labels.length,
cap: LABELS_MAX_LENGTH,
});
return labels.slice(0, LABELS_MAX_LENGTH);
const ordered = labels.toSorted(
(left, right) => labelTruncationPriority(left.val) - labelTruncationPriority(right.val),
);
const kept = ordered.slice(0, LABELS_MAX_LENGTH);
const droppedBlocks = ordered
.slice(LABELS_MAX_LENGTH)
.filter((label) => labelTruncationPriority(label.val) === 0).length;
if (droppedBlocks > 0) {
console.error("[aggregator] view label cap dropped hard-block labels", {
uri,
count: labels.length,
cap: LABELS_MAX_LENGTH,
droppedBlocks,
});
} else {
console.warn("[aggregator] view labels truncated to maxLength", {
uri,
count: labels.length,
cap: LABELS_MAX_LENGTH,
});
}
return kept;
}

/** `LabelView.src` is `string`; the lexicon's `Label.src` is a branded DID
Expand Down
43 changes: 43 additions & 0 deletions apps/aggregator/test/label-enforcement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Unit tests for the enforcement SQL builders' input guards. The builders are
* exported and interpolate the caller-supplied row alias into raw SQL, so a
* malformed alias must be rejected rather than reach the query text.
*/

import { type AcceptedLabelerPolicy } from "@emdash-cms/registry-moderation";
import { describe, expect, it } from "vitest";

import {
buildPackageEnforcementSql,
buildReleaseEnforcementSql,
} from "../src/routes/xrpc/label-enforcement.js";

const accepted: AcceptedLabelerPolicy[] = [{ did: "did:web:labels.example", redact: false }];

describe("buildReleaseEnforcementSql alias validation", () => {
it("rejects an alias that is not an identifier followed by a dot", () => {
expect(() =>
buildReleaseEnforcementSql(accepted, 0, { release: "r; DROP TABLE releases;--" }),
).toThrow(TypeError);
expect(() => buildReleaseEnforcementSql(accepted, 0, { package: "p" })).toThrow(TypeError);
});

it("accepts the default aliases", () => {
const { sql } = buildReleaseEnforcementSql(accepted, 0);
expect(sql).toContain("NOT EXISTS");
});
});

describe("buildPackageEnforcementSql alias validation", () => {
it("rejects an alias that is not an identifier followed by a dot", () => {
expect(() => buildPackageEnforcementSql(accepted, 0, "p; DROP TABLE packages;--")).toThrow(
TypeError,
);
expect(() => buildPackageEnforcementSql(accepted, 0, "p")).toThrow(TypeError);
});

it("accepts the default alias", () => {
const { sql } = buildPackageEnforcementSql(accepted, 0);
expect(sql).toContain("NOT EXISTS");
});
});
109 changes: 109 additions & 0 deletions apps/aggregator/test/read-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,115 @@ describe("getLatestRelease", () => {
const body = (await res.json()) as Record<string, unknown>;
expect(body["version"]).toBe("1.0.0");
});

// §10 override rule: a source's exact-CID assessment-passed +
// assessment-overridden pair suppresses that source's automated blocks for
// the release, mirroring `evaluateReleaseModerationCore`. The enforcement
// SQL must not exclude a release whose only live block is so suppressed.
const OVERRIDE_CID = "bafoverridecid";

it("includes a release whose re-issued automated block is suppressed by a same-source exact-CID override pair", async () => {
await seedLabeler(LABELER_DID, true);
await seedPackage({ slug: "demo", latestVersion: "1.0.0" });
await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID });
const uri = releaseUri("demo", "1.0.0");
await seedLabelState({ uri, val: "assessment-passed", cid: OVERRIDE_CID });
await seedLabelState({ uri, val: "assessment-overridden", cid: OVERRIDE_CID });
await seedLabelState({ uri, val: "malware", cid: OVERRIDE_CID });

const res = await SELF.fetch(
`https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`,
);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body["version"]).toBe("1.0.0");
});

it("includes a release carrying an override pair and no block", async () => {
await seedLabeler(LABELER_DID, true);
await seedPackage({ slug: "demo", latestVersion: "1.0.0" });
await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID });
const uri = releaseUri("demo", "1.0.0");
await seedLabelState({ uri, val: "assessment-passed", cid: OVERRIDE_CID });
await seedLabelState({ uri, val: "assessment-overridden", cid: OVERRIDE_CID });

const res = await SELF.fetch(
`https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`,
);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body["version"]).toBe("1.0.0");
});

it("excludes a release with an automated block and no override pair", async () => {
await seedLabeler(LABELER_DID, true);
await seedPackage({ slug: "demo", latestVersion: "1.0.0" });
await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID });
await seedLabelState({ uri: releaseUri("demo", "1.0.0"), val: "malware", cid: OVERRIDE_CID });

const res = await SELF.fetch(
`https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`,
);
expect(res.status).toBe(404);
});

it("still excludes when the override pair is from a different source than the block", async () => {
const OTHER_LABELER_DID = "did:web:other-labels.example";
await seedLabeler(LABELER_DID, true);
await seedLabeler(OTHER_LABELER_DID, true);
await seedPackage({ slug: "demo", latestVersion: "1.0.0" });
await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID });
const uri = releaseUri("demo", "1.0.0");
await seedLabelState({ uri, val: "malware", cid: OVERRIDE_CID });
await seedLabelState({
uri,
val: "assessment-passed",
cid: OVERRIDE_CID,
src: OTHER_LABELER_DID,
});
await seedLabelState({
uri,
val: "assessment-overridden",
cid: OVERRIDE_CID,
src: OTHER_LABELER_DID,
});

const res = await SELF.fetch(
`https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`,
);
expect(res.status).toBe(404);
});

it("still excludes when the override pair CID does not match the release", async () => {
await seedLabeler(LABELER_DID, true);
await seedPackage({ slug: "demo", latestVersion: "1.0.0" });
await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID });
const uri = releaseUri("demo", "1.0.0");
await seedLabelState({ uri, val: "malware", cid: OVERRIDE_CID });
await seedLabelState({ uri, val: "assessment-passed", cid: "bafstalecid" });
await seedLabelState({ uri, val: "assessment-overridden", cid: "bafstalecid" });

const res = await SELF.fetch(
`https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`,
);
expect(res.status).toBe(404);
});

it("never suppresses a security-yanked release block, even with an override pair", async () => {
await seedLabeler(LABELER_DID, true);
await seedPackage({ slug: "demo", latestVersion: "1.0.0" });
await seedRelease({ version: "1.0.0", cid: OVERRIDE_CID });
const uri = releaseUri("demo", "1.0.0");
await seedLabelState({ uri, val: "assessment-passed", cid: OVERRIDE_CID });
await seedLabelState({ uri, val: "assessment-overridden", cid: OVERRIDE_CID });
// `security-yanked` is issued CID-less (policy cidRule: forbidden).
await seedLabelState({ uri, val: "security-yanked" });

const res = await SELF.fetch(
`https://test/xrpc/${NSID.aggregatorGetLatestRelease}?did=${DID_A}&package=demo`,
);
expect(res.status).toBe(404);
});
});

describe("searchPackages", () => {
Expand Down
Loading
Loading