// Core type system for PDSuite checks. // The design contract lives in research/07-check-registry-format.md. export type Authority = 'spec' | 'lexicon' | 'interop' | 'reference' export type Severity = 'must' | 'should' | 'divergence' export type Verdict = 'pass' | 'fail' | 'warn' | 'skip' | 'error' export type Mode = 'readonly' | 'mutate' | 'lifecycle' export type Tier = 0 | 1 | 2 | 3 | 4 export interface Expectation { /** Addressable as # */ id: string severity: Severity /** Defaults to the check's authority if omitted */ source?: Authority statement: string } export interface AccountRequirement { id: string caps: string[] } /** Target capabilities a check can require. A capability names a target-level feature * the discovery pass cannot express as a single endpoint; the runner derives the set * from the TargetProfile and skips (never fails) a check whose requirement is unmet. * The vocabulary is closed so a typo'd capability fails fast at load time instead of * silently running the check. Derivation lives in discover.ts:deriveCapabilities. * * - oauth-as: the target hosts an OAuth authorization server, per a parseable * /.well-known/oauth-protected-resource document advertising authorization_servers. * - can-create-account: the run can create accounts on the target — a provisioner is * configured and com.atproto.server.createAccount is implemented. (With no * provisioner the runner already skips on requires.accounts; the capability is what * skips a lifecycle check that observes provisioning from the outside.) * - can-delete-account: the run can delete an account it provisioned — the * provisioner knows the account password and com.atproto.server.deleteAccount is * implemented. Lifecycle checks that delete an account declare this so a target * without deleteAccount (or a run with adopted, passwordless accounts) skips * instead of failing. */ export type Capability = 'oauth-as' | 'can-create-account' | 'can-delete-account' /** Every declared capability. Runner derivation must produce a subset of this list — * adding a capability means adding its derivation in the same change. */ export const KNOWN_CAPABILITIES: readonly Capability[] = ['oauth-as', 'can-create-account', 'can-delete-account'] /** Auth classification for an endpoint — the spec cannot enumerate this (documented gap), * so checks declare it and the generator defaults it from lexicon type. */ export type AuthClass = 'public-read' | 'authenticated-mutation' | 'admin' export interface CheckRequirements { accounts?: AccountRequirement[] endpoints?: string[] capabilities?: Capability[] /** Override the auth class assumed for generated contract checks. */ auth?: AuthClass } /** Per-run discovery data about the target, built by the discovery pass. */ export interface TargetProfile { url: string describeServer?: Record endpoints?: { implemented: string[] absent: string[] extra: string[] } oauth?: { protectedResource?: Record authorizationServer?: Record } /** Capabilities derived from the discovery data above. Populated by the runner at * the runAll boundary (after the endpoint inventory has filled `endpoints`) via * discover.ts:deriveCapabilities — not present on a raw discover() result. */ capabilities?: Set } export interface CheckContext { /** Target base URL, no trailing slash */ target: string profile: TargetProfile /** The check being executed (exposed for contract runners). */ check: Check /** Record an assertion against a declared expectation. */ assert(expectationId: string, condition: boolean, evidence?: unknown): void /** Record that an expectation is inapplicable in this run — its precondition did not hold * (e.g. a token invariant when no token was issued). Not a pass, not a fail. */ assertNotApplicable(expectationId: string, reason: string, evidence?: unknown): void /** Get a provisioned account handle declared in `requires.accounts`. */ account(id: string): Promise /** Provision an account NOT declared in requires (for lifecycle checks that must * observe account creation on the firehose). Present only when the target can * self-provision. */ provisionRaw?(req: AccountRequirement): Promise /** Minimal XRPC call against the target. */ xrpc(method: string, opts?: XrpcOpts): Promise /** Raw fetch against the target (for .well-known etc). */ fetch(path: string, init?: RequestInit): Promise } export interface XrpcOpts { params?: Record input?: unknown /** Raw request body (blob upload, CAR import). Overrides `input`; `contentType` sets * the Content-Type header (defaults to the lexicon-agnostic wildcard `*\/*`). */ bytes?: Uint8Array contentType?: string /** Force the HTTP method. Procedures (POST) with no request body need this, since the * client otherwise defaults to GET when `input` is absent. */ httpMethod?: 'GET' | 'POST' /** Bearer token; null = explicitly unauthenticated; 'admin' = the run's admin * credentials (HTTP Basic `admin:`), resolved by the runner. Checks fail * closed when the run has no admin credentials configured. */ auth?: string | null headers?: Record } export interface XrpcResult { status: number headers: Record body: unknown /** Raw response bytes. Always present so binary-output endpoints (sync CARs, blobs) * can be asserted on the wire; JSON `body` is parsed from these bytes when possible. */ rawBody: Uint8Array } export interface FetchCapture { status: number headers: Record bodyText: string } export interface AccountSession { did: string handle: string accessJwt: string /** The account password. Present only when the provisioner created the account * (and therefore knows the password); absent for adopted accounts that use an * app password or an external session. */ password?: string xrpc(method: string, opts?: XrpcOpts): Promise createRecord(input: { collection: string; record: Record; rkey?: string }): Promise latestCommit(): Promise<{ cid: string; rev: string }> /** Deactivate the account (com.atproto.server.deactivateAccount). */ deactivate(opts?: { deleteAfter?: string }): Promise /** Reactivate the account (com.atproto.server.activateAccount). */ activate(): Promise /** Delete the account (com.atproto.server.deleteAccount). Sends the provisioner-known * password plus any caller-supplied token; the reference additionally requires an * emailed token, so without one the call fails closed with 400 and callers must * branch on the status. Throws when the provisioner never knew the password. */ delete(opts?: { token?: string }): Promise } export interface Check { id: string title: string tier: Tier mode: Mode authority: Authority specRefs?: string[] requires?: CheckRequirements timeoutMs?: number tags?: string[] expectations: Expectation[] rationale: string references?: string[] kind?: 'scripted' | 'contract' generatedFrom?: { lexicon: string; pin: string } differential?: boolean run(ctx: CheckContext): Promise } // ---- DSL helpers ---- export function check(def: Check): Check { if (!def.rationale || def.rationale.trim().length === 0) { throw new Error(`check ${def.id}: rationale is mandatory`) } if (def.expectations.length === 0) { throw new Error(`check ${def.id}: must declare at least one expectation`) } const ids = new Set(def.expectations.map((e) => e.id)) if (ids.size !== def.expectations.length) { throw new Error(`check ${def.id}: duplicate expectation ids`) } return def } const exp = (severity: Severity, source?: Authority) => (id: string, statement: string): Expectation => ({ id, severity, source, statement }) export const must = exp('must') export const should = exp('should') export const divergence = exp('divergence', 'reference') // ---- Target profiles (co-owned divergence declarations) ---- /** A single acknowledged divergence, keyed by `#`. */ export interface Divergence { /** `#` — the exact expectation this waives. */ expectation: string /** Why the target diverges deliberately. Shown in the report, never hidden. */ note: string /** Link to the target's own documentation of the divergence. */ link?: string } /** A target profile: the set of divergences a target declares as deliberate. * Co-owned with the target's maintainers (see research/07 §5). A waived expectation is * annotated as acknowledged in the report — downgraded, never silently silenced. */ export interface Profile { target: string divergences: Divergence[] } export function profile(def: Profile): Profile { if (!def.target) throw new Error('profile: target is mandatory') for (const d of def.divergences) { if (!d.expectation.includes('#')) { throw new Error(`profile ${def.target}: divergence expectation must be #, got ${JSON.stringify(d.expectation)}`) } if (!d.note || d.note.trim().length === 0) { throw new Error(`profile ${def.target}: divergence ${d.expectation} must have a note`) } } return def } // ---- Results ---- export interface AssertionResult { expectation: Expectation verdict: Exclude evidence?: unknown /** Set when a target profile acknowledges this divergence. The verdict is downgraded to * 'warn' and annotated — a deliberate, documented divergence, not an accusation. */ acknowledged?: { note: string; link?: string } /** Set when the assertion is legitimately inapplicable at runtime (e.g. a token-property * invariant when no token was issued). Distinct from pass and from skip: the expectation * was considered, but the precondition to evaluate it did not hold. Counts as neither * pass nor fail; the reason is recorded in evidence. */ notApplicable?: boolean } export interface CheckResult { checkId: string verdict: Verdict skipReason?: string errorMessage?: string assertions: AssertionResult[] durationMs: number }