diff --git a/.github/workflows/pr89-seam-receipt.yml b/.github/workflows/pr89-seam-receipt.yml new file mode 100644 index 000000000..666582a3f --- /dev/null +++ b/.github/workflows/pr89-seam-receipt.yml @@ -0,0 +1,91 @@ +name: pr89-seam-receipt + +on: + pull_request: + paths: + - ".github/workflows/pr89-seam-receipt.yml" + - "design-notes/seam-spike/**" + - "openspec/changes/harden-pdpp-authorization-and-0-1-migration/**" + - "package.json" + - "packages/reference-contract/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "reference-implementation/**" + - "scripts/test-accounting/**" + - "test-accounting.manifest.json" + push: + branches: [main] + paths: + - ".github/workflows/pr89-seam-receipt.yml" + - "design-notes/seam-spike/**" + - "openspec/changes/harden-pdpp-authorization-and-0-1-migration/**" + - "package.json" + - "packages/reference-contract/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "reference-implementation/**" + - "scripts/test-accounting/**" + - "test-accounting.manifest.json" + workflow_dispatch: {} + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + pr89-seam-receipt: + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: pdpp_pr89 + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d pdpp_pr89" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + PDPP_TEST_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/pdpp_pr89 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + cache: pnpm + node-version: "24" + - name: Install dependencies + env: + PATCHRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + run: pnpm install --frozen-lockfile + - name: Test receipt derivation and fail-closed checks + run: pnpm --filter pdpp-reference-implementation test:seam:pr89:receipt + - name: Validate PR89 OpenSpec target + run: pnpm dlx @fission-ai/openspec@1.8.0 validate harden-pdpp-authorization-and-0-1-migration --strict + - name: Execute all seven cases and generate receipt + id: seam + continue-on-error: true + run: pnpm --filter pdpp-reference-implementation test:seam:pr89 -- --backend postgresql + - name: Validate generated receipt against executed evidence + id: receipt + if: always() + continue-on-error: true + run: pnpm --filter pdpp-reference-implementation check:seam:pr89:receipt + - name: Require execution and receipt validation + if: always() + env: + RECEIPT_OUTCOME: ${{ steps.receipt.outcome }} + SEAM_OUTCOME: ${{ steps.seam.outcome }} + run: | + test "$SEAM_OUTCOME" = "success" + test "$RECEIPT_OUTCOME" = "success" diff --git a/.github/workflows/reference-implementation.yml b/.github/workflows/reference-implementation.yml index 9b0cda8d8..018f4bf25 100644 --- a/.github/workflows/reference-implementation.yml +++ b/.github/workflows/reference-implementation.yml @@ -176,6 +176,8 @@ jobs: if: steps.changes.outputs.reference_impacting == 'true' env: PDPP_TEST_CONCURRENCY: "2" + PDPP_TEST_FILE_HARD_TIMEOUT_MS: "900000" + PDPP_TEST_FILE_TIMEOUT_MS: "120000" run: pnpm --dir reference-implementation run test - name: Report non-reference skip diff --git a/README.md b/README.md index 85971cefd..3934631aa 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ reference implementation, and supporting documentation. The protocol is defined by the `spec-*.md` files at the repository root. Not every root spec carries the same authority: each file states its own status in -a header near the top, and that header governs. Two of them are the normative +a header near the top, and that header governs. Three of them are the normative protocol; the rest are informative rationale, illustrative examples, or historical material superseded by the normative text. Where any downstream document, example, or superseded spec disagrees with the normative specs, the @@ -23,6 +23,7 @@ normative specs prevail. **Normative** — the protocol itself. Read these to implement PDPP: - [`spec-core.md`](spec-core.md) — core protocol: grants, sources, records, and the query surface (*Normative draft*). Core Section 8 is the authoritative definition of the resource-server query interface. +- [`spec-discovery-and-trust.md`](spec-discovery-and-trust.md): source onboarding, provider-native discovery, authority, and accepted declaration revisions (*Companion specification draft*) - [`spec-collection-profile.md`](spec-collection-profile.md) — how a source is declared and collected; a companion profile to Core (*Companion profile draft*) **Informative** — rationale and context. These explain and situate the @@ -79,8 +80,9 @@ are not the protocol boundary. The durable boundary between them lives in the This repository uses a strict authority order: -1. **Root PDPP specs** (`spec-*.md`) define the protocol. The two normative - specs (`spec-core.md`, `spec-collection-profile.md`) define protocol +1. **Root PDPP specs** (`spec-*.md`) define the protocol. The three normative + specs (`spec-core.md`, `spec-discovery-and-trust.md`, and + `spec-collection-profile.md`) define protocol semantics; the other root specs are informative, illustrative, or superseded, as each file's status header states. 2. **Code and tests** define what the reference implementation actually does. diff --git a/apps/console/src/app/(console)/components/views/standing-demo-data.ts b/apps/console/src/app/(console)/components/views/standing-demo-data.ts index 80a9c4d62..604d6650b 100644 --- a/apps/console/src/app/(console)/components/views/standing-demo-data.ts +++ b/apps/console/src/app/(console)/components/views/standing-demo-data.ts @@ -150,6 +150,7 @@ const FAILED_RUNS: RunSummary[] = [ const PENDING: PendingApproval[] = [ { approval_id: "appr_atlas", + batch: false, client_id: "Atlas Mortgage", created_at: iso(0), grant_preview: { diff --git a/apps/console/src/app/(console)/components/views/standing-view-model.test.ts b/apps/console/src/app/(console)/components/views/standing-view-model.test.ts index ffdbaea04..1fb13f11b 100644 --- a/apps/console/src/app/(console)/components/views/standing-view-model.test.ts +++ b/apps/console/src/app/(console)/components/views/standing-view-model.test.ts @@ -174,6 +174,7 @@ test("relDay produces calm relative labels", () => { test("hero is DECIDE when an approval is pending", () => { const pending: PendingApproval = { approval_id: "a1", + batch: false, client_id: "Atlas Mortgage", created_at: NOW.toISOString(), grant_preview: { streams: [{ name: "pay_statements" }, { name: "transactions" }] }, @@ -264,6 +265,7 @@ test("failed syncs/traces alone do NOT drive the alarm — only the rendered-ver test("decide wins over alarm", () => { const pending = { approval_id: "a1", + batch: false, created_at: NOW.toISOString(), kind: "consent", object: "approval", diff --git a/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.test.ts b/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.test.ts new file mode 100644 index 000000000..d15c5c665 --- /dev/null +++ b/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.test.ts @@ -0,0 +1,182 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ApprovalReview, ConsentApprovalReview, SingleConsentApprovalArtifact } from "../../../lib/ref-client.ts"; +import { ApprovalReview as ApprovalReviewView } from "./approval-review.tsx"; + +(globalThis as { React?: typeof React }).React = React; + +const APPROVAL_BUTTON_RE = /Approve and issue grant/; +const APPROVAL_CONFIRMATION_RE = /name="approval_confirmation"[^>]*value="approve"/; +const APPROVAL_REVISION_RE = + /name="approval_review_revision"[^>]*value="reference\.approval-review\.v1:sha256:reviewDigest"/; +const REQUEST_URI_RE = /name="request_uri"[^>]*value="urn:pdpp:pending-consent:dc_reviewed"/; +const NO_APPROVAL_BUTTON_RE = /]*>Approve and issue grant<\/button>/; +const NO_DEVICE_SECRET_RE = /device_code|user_code|params_json/; +const OWNER_DEVICE_CONTROL_RE = /Owner device control/; +const OWNER_DEVICE_WARNING_RE = /not a scoped third-party data grant/; +const REVIEW_CONTINUE_RE = /Continue to approval/; +const BATCH_NON_ACTIONABLE_RE = /Batch approval is not available from this console review/; +const BATCH_ARTIFACT_VERSION_RE = /reference\.batch-approval-review\.v1/; +const NO_APPROVAL_ID_RE = /name="approval_id"[^>]*value="dc_/; +const NO_MUTABLE_CONSENT_RE = /name="subject_id"|name="ai_training_consented"/; +const NO_OWNER_DEVICE_SCOPE_RE = /Resolved streams|Purpose/; + +function noAction(): void { + // Rendering proof only. +} + +function artifact(overrides: Partial = {}): SingleConsentApprovalArtifact { + return { + access_mode: "continuous", + ai_training_consented: true, + client: { + client_display: { + name: "Concert Finder", + policy_uri: "https://concert.example/policy", + tos_uri: "data:text/html,owned", + uri: "javascript:alert(1)", + }, + client_id: "concert_finder", + registration_mode: "pre_registered_public", + }, + expires_at: "2026-08-11T12:10:00.000Z", + client_claims: { commitments: ["Use only for concert recommendations"] }, + purpose_code: "https://pdpp.org/purpose/ai_training", + purpose_description: "Train a concert-ranking model.", + resolved_streams: [ + { + fields: ["name", "popularity"], + instance_ids: ["cin_music_primary", "cin_music_backup"], + name: "top_artists", + resources: ["artist_1", "artist_2"], + time_constraint: { field: "played_at", since: "2026-01-01", until: "2026-02-01" }, + }, + ], + retention: { max_duration: "P30D", on_expiry: "delete" }, + selection_preset: "music-profile", + source: { id: "spotify", kind: "connector" }, + source_declaration: { + accepted_revision_reference: "accepted-rev-1", + digest: "sha256:sourceDigest", + publisher_attribution: { id: "https://pdpp.dev/reference-implementation", status: "unverified" }, + resource_authority: { authority_binding: "https://spotify.example/pdpp", status: "verified" }, + version: "reference.source-declaration.v1", + }, + subject: { id: "owner_local" }, + version: "reference.approval-review.v1", + ...overrides, + }; +} + +function consent(overrides: Partial = {}): ConsentApprovalReview { + return { + approval_id: "apr_review_safe", + approval_review: artifact(), + approval_review_revision: "reference.approval-review.v1:sha256:reviewDigest", + batch: false, + kind: "consent", + object: "approval_review", + request_uri: "urn:pdpp:pending-consent:dc_reviewed", + ...overrides, + }; +} + +function render(detail: ApprovalReview, confirm = false): string { + return renderToStaticMarkup(ApprovalReviewView({ approveAction: noAction, confirm, denyAction: noAction, detail })); +} + +test("single consent review renders PR114 artifact authority and no approval submit before confirmation", () => { + const html = render(consent()); + for (const label of [ + "reference.approval-review.v1", + "owner_local", + "Concert Finder", + "concert_finder", + "spotify", + "reference.source-declaration.v1 / sha256:sourceDigest", + "accepted revision accepted-rev-1", + "publisher https://pdpp.dev/reference-implementation (unverified)", + "resource authority verified: https://spotify.example/pdpp", + "Use only for concert recommendations", + "music-profile", + "Train a concert-ranking model.", + "continuous", + "true", + "P30D", + "cin_music_primary, cin_music_backup", + "name, popularity", + "artist_1, artist_2", + "played_at: 2026-01-01 to 2026-02-01", + "Exact reviewed artifact", + ]) { + assert.match(html, new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + assert.match(html, REVIEW_CONTINUE_RE); + assert.doesNotMatch(html, NO_APPROVAL_BUTTON_RE); + assert.doesNotMatch(html, NO_DEVICE_SECRET_RE); +}); + +test("final consent confirmation carries the exact reviewed request uri and revision", () => { + const html = render(consent(), true); + assert.match(html, APPROVAL_BUTTON_RE); + assert.match(html, APPROVAL_CONFIRMATION_RE); + assert.match(html, APPROVAL_REVISION_RE); + assert.match(html, REQUEST_URI_RE); + assert.doesNotMatch(html, NO_APPROVAL_ID_RE); + assert.doesNotMatch(html, NO_MUTABLE_CONSENT_RE); +}); + +test("batch review is explicit and non-actionable in the console", () => { + const html = render( + consent({ + approval_review: { + access_mode: null, + approved_source_indexes: [0], + client: artifact().client, + expires_at: "2026-08-11T12:10:00.000Z", + parent_package_id: "pkg_1", + source_narrowing: { "0": { streams: ["top_artists"] } }, + sources: [ + { + access_mode: "continuous", + client_claims: { commitments: ["Only use this approved source for batch recommendations"] }, + index: 0, + purpose_code: "https://pdpp.org/purpose/ai_training", + purpose_description: "Train a concert-ranking model.", + resolved_streams: artifact().resolved_streams, + retention: { max_duration: "P30D" }, + selection_preset: "music-profile", + source: { id: "spotify", kind: "connector" }, + source_declaration: { digest: "sha256:sourceDigest", version: "reference.source-declaration.v1" }, + }, + ], + subject: { id: "owner_local" }, + version: "reference.batch-approval-review.v1", + }, + batch: true, + }), + true + ); + assert.match(html, BATCH_ARTIFACT_VERSION_RE); + assert.match(html, BATCH_NON_ACTIONABLE_RE); + assert.doesNotMatch(html, NO_APPROVAL_BUTTON_RE); +}); + +test("owner-device review warns about owner control without inventing grant scope or purpose", () => { + const html = render({ + approval_id: "apr_owner", + client_id: "owner_cli", + created_at: "2026-08-11T12:00:00.000Z", + expires_at: "2026-08-11T12:10:00.000Z", + kind: "owner_device", + object: "approval_review", + }); + assert.match(html, OWNER_DEVICE_CONTROL_RE); + assert.match(html, OWNER_DEVICE_WARNING_RE); + assert.doesNotMatch(html, NO_OWNER_DEVICE_SCOPE_RE); +}); diff --git a/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.tsx b/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.tsx new file mode 100644 index 000000000..029552597 --- /dev/null +++ b/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.tsx @@ -0,0 +1,355 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { IcButton, IcTimestamp } from "@pdpp/brand-react"; +import type { + ApprovalReview as ApprovalReviewData, + ApprovalReviewJson, + BatchConsentApprovalArtifact, + ConsentApprovalArtifact, + ReviewedStreamArtifact, + SingleConsentApprovalArtifact, + SourceDeclarationArtifact, +} from "../../../lib/ref-client.ts"; + +function jsonLabel(value: ApprovalReviewJson | null | undefined): string { + if (value === null || value === undefined) { + return "Not provided"; + } + return typeof value === "string" ? value : JSON.stringify(value); +} + +function listLabel(values: readonly string[] | null | undefined, empty: string): string { + return values?.length ? values.join(", ") : empty; +} + +function claimsLabel(claims: { commitments: string[] } | null): string { + return claims ? claims.commitments.join("; ") : "None"; +} + +function safeHref(value: string | null | undefined): string | null { + if (!value) { + return null; + } + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" ? url.href : null; + } catch { + return null; + } +} + +function sourceDeclarationEvidenceLabel(sourceDeclaration: SourceDeclarationArtifact): string { + const evidence = [`${sourceDeclaration.version} / ${sourceDeclaration.digest}`]; + if (sourceDeclaration.accepted_revision_reference) { + evidence.push(`accepted revision ${sourceDeclaration.accepted_revision_reference}`); + } + if (sourceDeclaration.publisher_attribution) { + evidence.push( + `publisher ${sourceDeclaration.publisher_attribution.id} (${sourceDeclaration.publisher_attribution.status})` + ); + } + if (sourceDeclaration.resource_authority) { + const authority = + sourceDeclaration.resource_authority.status === "verified" + ? `${sourceDeclaration.resource_authority.status}: ${sourceDeclaration.resource_authority.authority_binding}` + : sourceDeclaration.resource_authority.status; + evidence.push(`resource authority ${authority}`); + } + return evidence.join("; "); +} + +function DisplayLink({ label, value }: { label: string; value: string | null | undefined }) { + if (!value) { + return null; + } + const href = safeHref(value); + return ( + <> +
{label}
+
+ {href ? ( + + {value} + + ) : ( + {value} + )} +
+ + ); +} + +function StreamFacts({ streams }: { streams: readonly ReviewedStreamArtifact[] }) { + return ( +
    + {streams.map((stream) => ( +
  • + {stream.name} +
    +
    Instance IDs
    +
    {listLabel(stream.instance_ids, "No closed instance ids")}
    +
    Fields
    +
    {listLabel(stream.fields, "All fields")}
    +
    Resources
    +
    {listLabel(stream.resources, "All records")}
    +
    Time
    +
    + {stream.time_constraint + ? `${stream.time_constraint.field}: ${stream.time_constraint.since ?? "beginning"} to ${ + stream.time_constraint.until ?? "open-ended" + }` + : "No time limit"} +
    +
    +
  • + ))} +
+ ); +} + +function SingleArtifactFacts({ artifact }: { artifact: SingleConsentApprovalArtifact }) { + const display = artifact.client.client_display ?? {}; + return ( + <> +
+

+ Reviewed authority +

+
+
Artifact version
+
{artifact.version}
+
Subject
+
{artifact.subject.id}
+
Client
+
+ {display.name ?? artifact.client.client_id} ({artifact.client.client_id},{" "} + {artifact.client.registration_mode}) +
+ + + +
Source
+
+ {artifact.source.kind}: {artifact.source.id} +
+
Source declaration
+
{sourceDeclarationEvidenceLabel(artifact.source_declaration)}
+
Client claims
+
{claimsLabel(artifact.client_claims)}
+
Selection preset
+
{artifact.selection_preset ?? "None"}
+
Purpose
+
{artifact.purpose_description ?? artifact.purpose_code}
+
Access mode
+
{artifact.access_mode}
+
AI training decision
+
{artifact.ai_training_consented === null ? "Not applicable" : String(artifact.ai_training_consented)}
+
Retention
+
{jsonLabel(artifact.retention as ApprovalReviewJson | null)}
+
Request expiry
+
{artifact.expires_at ? : "No expiry in artifact"}
+
+
+
+

+ Resolved streams +

+ +
+ + ); +} + +function BatchArtifactFacts({ artifact }: { artifact: BatchConsentApprovalArtifact }) { + return ( + <> +
+

+ Reviewed batch authority +

+
+
Artifact version
+
{artifact.version}
+
Subject
+
{artifact.subject.id}
+
Client
+
{artifact.client.client_display?.name ?? artifact.client.client_id}
+
Approved source indexes
+
{artifact.approved_source_indexes.join(", ") || "None"}
+
Source narrowing
+
{jsonLabel(artifact.source_narrowing as ApprovalReviewJson)}
+
Request expiry
+
{artifact.expires_at ? : "No expiry in artifact"}
+
+
+ {artifact.sources.map((source) => ( +
+

+ Source {source.index} +

+
+
Source
+
+ {source.source.kind}: {source.source.id} +
+
Source declaration
+
{sourceDeclarationEvidenceLabel(source.source_declaration)}
+
Client claims
+
{claimsLabel(source.client_claims)}
+
Selection preset
+
{source.selection_preset ?? "None"}
+
Purpose
+
{source.purpose_description ?? source.purpose_code}
+
Access mode
+
{source.access_mode}
+
Retention
+
{jsonLabel(source.retention as ApprovalReviewJson | null)}
+
+ +
+ ))} + + ); +} + +function ConsentFacts({ artifact }: { artifact: ConsentApprovalArtifact }) { + if (artifact.version === "reference.batch-approval-review.v1") { + return ; + } + return ; +} + +function ExactArtifactJson({ artifact }: { artifact: ConsentApprovalArtifact }) { + return ( +
+

+ Exact reviewed artifact +

+
+        {JSON.stringify(artifact, null, 2)}
+      
+
+ ); +} + +export function ApprovalReview({ + approveAction, + confirm, + denyAction, + detail, + error, +}: { + approveAction: (formData: FormData) => void | Promise; + confirm: boolean; + denyAction: (formData: FormData) => void | Promise; + detail: ApprovalReviewData; + error?: string; +}) { + const reviewHref = `/grants/approvals/${encodeURIComponent(detail.approval_id)}`; + const artifact = detail.kind === "consent" ? detail.approval_review : undefined; + const isBatch = artifact?.version === "reference.batch-approval-review.v1"; + const appName = + detail.kind === "consent" + ? (detail.approval_review.client.client_display?.name ?? detail.approval_review.client.client_id) + : detail.client_id; + let decisionControl = ( +
+ + Continue to approval + +
+ + + + Deny request + +
+
+ ); + if (confirm) { + decisionControl = ( +
+

+ Approve {appName} for the exact request shown above. This action issues the grant only if the reviewed + revision is still current. +

+ + + + {detail.kind === "consent" ? ( + <> + + + + ) : null} +
+ + Approve and issue grant + + + Return to review + +
+
+ ); + } + if (detail.kind === "consent" && isBatch) { + decisionControl = ( +

+ Batch approval is not available from this console review. Use the hosted source-review ceremony for this + request. +

+ ); + } + + return ( +
+
+

Pending approval

+

{confirm ? "Confirm approval" : "Review request"}

+

+ {detail.kind === "consent" + ? `${appName} requests access to your data.` + : `${appName} requests owner-device authorization.`} +

+
+ {error ? ( +

+ Approval was not issued. Review the newly materialized request before trying again: {error} +

+ ) : null} + {artifact ? ( + <> + + + + ) : ( +
+

+ Owner device control +

+

+ This authorizes owner-level control for this device flow. It is not a scoped third-party data grant, so + there is no data scope or purpose to review. +

+

+ Request expires at {detail.kind === "owner_device" ? : null}. +

+
+ )} +
+

+ Decision +

+ {decisionControl} +
+
+ ); +} diff --git a/apps/console/src/app/(console)/grants/approvals/[approvalId]/page.tsx b/apps/console/src/app/(console)/grants/approvals/[approvalId]/page.tsx new file mode 100644 index 000000000..fa487ffd5 --- /dev/null +++ b/apps/console/src/app/(console)/grants/approvals/[approvalId]/page.tsx @@ -0,0 +1,44 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { notFound } from "next/navigation"; +import { RecordroomShellWithPalette } from "../../../components/recordroom-shell-with-palette.tsx"; +import { + type ApprovalReview as ApprovalReviewData, + getPendingApprovalReview, + RefNotFoundError, +} from "../../../lib/ref-client.ts"; +import { approveReviewedPendingApprovalAction, denyPendingApprovalAction } from "../../pending-actions.ts"; +import { ApprovalReview } from "./approval-review.tsx"; + +export const dynamic = "force-dynamic"; + +export default async function ApprovalReviewPage({ + params, + searchParams, +}: { + params: Promise<{ approvalId: string }>; + searchParams: Promise<{ approval_error?: string; confirm?: string }>; +}) { + const [{ approvalId }, query] = await Promise.all([params, searchParams]); + let detail: ApprovalReviewData; + try { + detail = await getPendingApprovalReview(approvalId); + } catch (err) { + if (err instanceof RefNotFoundError) { + notFound(); + } + throw err; + } + return ( + + + + ); +} diff --git a/apps/console/src/app/(console)/grants/client-caption.ts b/apps/console/src/app/(console)/grants/client-caption.ts new file mode 100644 index 000000000..cec53ebd9 --- /dev/null +++ b/apps/console/src/app/(console)/grants/client-caption.ts @@ -0,0 +1,40 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +const TECHNICAL_CLIENT_ID_RE = /^cli_[a-z0-9]+$/i; +const WWW_PREFIX_RE = /^www\./; + +export function looksLikeTechnicalClientId(value: string): boolean { + return TECHNICAL_CLIENT_ID_RE.test(value); +} + +export function clientOriginCaption(value: string): string | null { + try { + const url = new URL(value); + const host = url.hostname.replace(WWW_PREFIX_RE, ""); + return host ? `client ${host}` : null; + } catch { + return null; + } +} + +export function technicalClientCaption(clientId: string | null | undefined): string | null { + const trimmed = clientId?.trim(); + if (!trimmed) { + return null; + } + return ( + clientOriginCaption(trimmed) ?? (looksLikeTechnicalClientId(trimmed) ? "registered client" : `client ${trimmed}`) + ); +} + +export function clientCaption(client: { + client?: { client_name?: string | null } | null; + client_id?: string | null; +}): string | null { + const name = client.client?.client_name?.trim(); + if (name) { + return `client ${name}`; + } + return technicalClientCaption(client.client_id); +} diff --git a/apps/console/src/app/(console)/grants/grants-demo-data.ts b/apps/console/src/app/(console)/grants/grants-demo-data.ts index cca72b492..73f458ea9 100644 --- a/apps/console/src/app/(console)/grants/grants-demo-data.ts +++ b/apps/console/src/app/(console)/grants/grants-demo-data.ts @@ -12,6 +12,7 @@ export function buildGrantsDemoData(): { data: [ { approval_id: "apr_demo_agent_review", + batch: false, client_id: "https://agent.example.test/oauth/client", created_at: "2026-07-01T12:04:00.000Z", grant_preview: { diff --git a/apps/console/src/app/(console)/grants/page.invariants.test.ts b/apps/console/src/app/(console)/grants/page.invariants.test.ts index 35607d605..f642175ef 100644 --- a/apps/console/src/app/(console)/grants/page.invariants.test.ts +++ b/apps/console/src/app/(console)/grants/page.invariants.test.ts @@ -16,10 +16,18 @@ import { fileURLToPath } from "node:url"; const HERE = fileURLToPath(new URL(".", import.meta.url)); const PAGE_FILE = `${HERE}page.tsx`; +const PENDING_APPROVAL_ROW_FILE = `${HERE}pending-approval-row.tsx`; +// The caption helpers moved to a shared module so grant-packages pages could +// reuse them instead of re-inlining the same raw-client_id-avoidance logic. +const CLIENT_CAPTION_FILE = `${HERE}client-caption.ts`; -const CLIENT_CAPTION_HELPER_RE = /function grantClientCaption\(/; -const CLIENT_ORIGIN_CAPTION_HELPER_RE = /function clientOriginCaption\(/; +const PAGE_USES_CLIENT_CAPTION_RE = /clientCaption\(grant\)/; +const PAGE_USES_TECHNICAL_CLIENT_CAPTION_RE = /technicalClientCaption\(approval\.client_id\)/; +const CLIENT_CAPTION_HELPER_RE = /export function clientCaption\(/; +const TECHNICAL_CLIENT_CAPTION_HELPER_RE = /export function technicalClientCaption\(/; +const CLIENT_ORIGIN_CAPTION_HELPER_RE = /export function clientOriginCaption\(/; const RAW_CLIENT_CAPTION_RE = /client\s+\{grant\.client_id\}/; +const RAW_APPROVAL_CLIENT_ID_RE = /client\s+\{approval\.client_id/; // C6: the Pending approvals section collapses entirely at zero — it must be // gated on a non-empty length, never rendered unconditionally with an @@ -28,10 +36,16 @@ const PENDING_SECTION_GATED_RE = /approvals\.data\.length > 0 \? \(\s*
{ - const src = await readFile(PAGE_FILE, "utf8"); - assert.match(src, CLIENT_CAPTION_HELPER_RE); - assert.match(src, CLIENT_ORIGIN_CAPTION_HELPER_RE); - assert.doesNotMatch(src, RAW_CLIENT_CAPTION_RE); + const pageSrc = await readFile(PAGE_FILE, "utf8"); + const pendingApprovalRowSrc = await readFile(PENDING_APPROVAL_ROW_FILE, "utf8"); + const captionSrc = await readFile(CLIENT_CAPTION_FILE, "utf8"); + assert.match(pageSrc, PAGE_USES_CLIENT_CAPTION_RE); + assert.match(pendingApprovalRowSrc, PAGE_USES_TECHNICAL_CLIENT_CAPTION_RE); + assert.match(captionSrc, CLIENT_CAPTION_HELPER_RE); + assert.match(captionSrc, TECHNICAL_CLIENT_CAPTION_HELPER_RE); + assert.match(captionSrc, CLIENT_ORIGIN_CAPTION_HELPER_RE); + assert.doesNotMatch(pageSrc, RAW_CLIENT_CAPTION_RE); + assert.doesNotMatch(pendingApprovalRowSrc, RAW_APPROVAL_CLIENT_ID_RE); }); test("grants page collapses the Pending approvals section when there are zero pending", async () => { diff --git a/apps/console/src/app/(console)/grants/page.tsx b/apps/console/src/app/(console)/grants/page.tsx index 33cbb395e..fda9efbe4 100644 --- a/apps/console/src/app/(console)/grants/page.tsx +++ b/apps/console/src/app/(console)/grants/page.tsx @@ -1,7 +1,7 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -import { buttonVariants, IcButton, IcTimestamp } from "@pdpp/brand-react"; +import { buttonVariants, IcTimestamp } from "@pdpp/brand-react"; import { formatSourceForDisplay, grantRowLabel } from "@pdpp/display"; import { DataList, PageHeader, Section, StatusBadge } from "@pdpp/operator-ui/components/primitives"; import { GRANT_LIFECYCLE_VOCABULARY } from "@pdpp/operator-ui/components/status-vocabularies"; @@ -19,7 +19,9 @@ import { listPendingApprovals, type PendingApproval, } from "../lib/ref-client.ts"; -import { approvePendingApprovalAction, denyPendingApprovalAction } from "./pending-actions.ts"; +import { clientCaption } from "./client-caption.ts"; +import { denyPendingApprovalAction } from "./pending-actions.ts"; +import { PendingApprovalRow } from "./pending-approval-row.tsx"; export const dynamic = "force-dynamic"; @@ -45,37 +47,6 @@ function listHref(params: Params, overrides: Partial = {}): string { return qs ? `/grants?${qs}` : "/grants"; } -const TECHNICAL_CLIENT_ID_RE = /^cli_[a-z0-9]+$/i; -const WWW_PREFIX_RE = /^www\./; - -function looksLikeTechnicalClientId(value: string): boolean { - return TECHNICAL_CLIENT_ID_RE.test(value); -} - -function clientOriginCaption(value: string): string | null { - try { - const url = new URL(value); - const host = url.hostname.replace(WWW_PREFIX_RE, ""); - return host ? `client ${host}` : null; - } catch { - return null; - } -} - -function grantClientCaption(grant: GrantSummary): string | null { - const name = grant.client?.client_name?.trim(); - if (name) { - return `client ${name}`; - } - const clientId = grant.client_id?.trim(); - if (!clientId) { - return null; - } - return ( - clientOriginCaption(clientId) ?? (looksLikeTechnicalClientId(clientId) ? "registered client" : `client ${clientId}`) - ); -} - export default async function GrantsPage({ searchParams }: { searchParams: Promise }) { const params = await searchParams; const filters = { @@ -141,7 +112,7 @@ export default async function GrantsPage({ searchParams }: { searchParams: Promi {approvals.data.map((approval) => (
  • - +
  • ))}
    @@ -205,44 +176,6 @@ export default async function GrantsPage({ searchParams }: { searchParams: Promi ); } -function PendingApprovalRow({ approval }: { approval: PendingApproval }) { - const previewStreams = Array.isArray(approval.grant_preview?.streams) - ? approval.grant_preview.streams.flatMap((stream) => { - const name = typeof stream === "string" ? stream : stream?.name || ""; - return name ? [name] : []; - }) - : []; - - return ( -
    -
    -
    - {approval.approval_id} - - - - -
    -
    - client {approval.client_id ?? "—"} - {approval.grant_preview?.source ? ` · source ${formatSourceForDisplay(approval.grant_preview.source)}` : ""} - {previewStreams.length ? ` · streams ${previewStreams.join(", ")}` : ""} -
    -
    -
    - - - - Approve - - - Deny - -
    -
    - ); -} - function GrantRow({ grant, href, @@ -255,7 +188,7 @@ function GrantRow({ peeked: boolean; }) { const packageHref = grant.grant_package_id ? `/grants/packages/${encodeURIComponent(grant.grant_package_id)}` : null; - const clientCaption = grantClientCaption(grant); + const clientCaptionText = clientCaption(grant); // Shared row content rendered inside both the mobile and desktop links. const rowContent = ( @@ -266,12 +199,12 @@ function GrantRow({
    {grantRowLabel(grant)} - {clientCaption ? ( + {clientCaptionText ? ( - {clientCaption} + {clientCaptionText} ) : null}
    diff --git a/apps/console/src/app/(console)/grants/pending-actions.ts b/apps/console/src/app/(console)/grants/pending-actions.ts index 94508ca2b..28e20896b 100644 --- a/apps/console/src/app/(console)/grants/pending-actions.ts +++ b/apps/console/src/app/(console)/grants/pending-actions.ts @@ -23,24 +23,34 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : "Unexpected approval action failure"; } -export async function approvePendingApprovalAction(formData: FormData) { - await requireDashboardAccess("/grants#pending-approvals"); +export async function approveReviewedPendingApprovalAction(formData: FormData) { const kind = asString(formData.get("kind")) as "consent" | "owner_device"; const approvalId = asString(formData.get("approval_id")); - const subjectId = asString(formData.get("subject_id")) || undefined; + const requestUri = asString(formData.get("request_uri")); + const approvalReviewRevision = asString(formData.get("approval_review_revision")); + const confirmation = asString(formData.get("approval_confirmation")); + if (confirmation !== "approve") { + redirect(`/grants/approvals/${encodeURIComponent(approvalId)}`); + } + await requireDashboardAccess(`/grants/approvals/${encodeURIComponent(approvalId)}?confirm=1`); let error: string | undefined; try { await approvePendingApproval({ approvalId, + approvalReviewRevision, kind, - subjectId, + requestUri, }); } catch (err) { error = errorMessage(err); } - redirect(baseHref(error)); + redirect( + error + ? `/grants/approvals/${encodeURIComponent(approvalId)}?approval_error=${encodeURIComponent(error)}` + : "/grants" + ); } export async function denyPendingApprovalAction(formData: FormData) { diff --git a/apps/console/src/app/(console)/grants/pending-approval-row.test.ts b/apps/console/src/app/(console)/grants/pending-approval-row.test.ts new file mode 100644 index 000000000..e36a8902d --- /dev/null +++ b/apps/console/src/app/(console)/grants/pending-approval-row.test.ts @@ -0,0 +1,37 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { PendingApproval } from "../lib/ref-client.ts"; +import { PendingApprovalRow } from "./pending-approval-row.tsx"; + +(globalThis as { React?: typeof React }).React = React; + +const DENY_LABEL_RE = /Deny request/; +const DENY_TARGET_RE = /Deny data-access request apr_queue_only/; +const NO_APPROVAL_RE = /Approve|issue grant/; +const REVIEW_LABEL_RE = /Review request/; + +function noAction(): void { + // Rendering proof only. +} + +test("pending approval queue renders review and request-specific denial, never approval", () => { + const approval: PendingApproval = { + approval_id: "apr_queue_only", + batch: false, + client_id: "concert_finder", + created_at: "2026-08-11T12:00:00.000Z", + grant_preview: { source: { id: "spotify", kind: "connector" }, streams: [{ name: "top_artists" }] }, + kind: "consent", + object: "approval", + }; + const html = renderToStaticMarkup(PendingApprovalRow({ approval, denyAction: noAction })); + assert.match(html, REVIEW_LABEL_RE); + assert.match(html, DENY_LABEL_RE); + assert.match(html, DENY_TARGET_RE); + assert.doesNotMatch(html, NO_APPROVAL_RE); +}); diff --git a/apps/console/src/app/(console)/grants/pending-approval-row.tsx b/apps/console/src/app/(console)/grants/pending-approval-row.tsx new file mode 100644 index 000000000..4f60f0658 --- /dev/null +++ b/apps/console/src/app/(console)/grants/pending-approval-row.tsx @@ -0,0 +1,64 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { buttonVariants, IcButton, IcTimestamp } from "@pdpp/brand-react"; +import { formatSourceForDisplay } from "@pdpp/display"; +import { StatusBadge } from "@pdpp/operator-ui/components/primitives"; +import Link from "next/link"; +import type { PendingApproval } from "../lib/ref-client.ts"; +import { technicalClientCaption } from "./client-caption.ts"; + +/** Queue row only. It can route to review or deny; grant issuance is absent. */ +export function PendingApprovalRow({ + approval, + denyAction, +}: { + approval: PendingApproval; + denyAction: (formData: FormData) => void | Promise; +}) { + const previewStreams = Array.isArray(approval.grant_preview?.streams) + ? approval.grant_preview.streams.flatMap((stream) => { + const name = typeof stream === "string" ? stream : stream?.name || ""; + return name ? [name] : []; + }) + : []; + const denialTarget = approval.kind === "consent" ? "data-access request" : "owner-device authorization"; + + return ( +
    +
    +
    + {approval.approval_id} + + + + +
    +
    + {technicalClientCaption(approval.client_id) ?? "client —"} + {approval.grant_preview?.source ? ` · source ${formatSourceForDisplay(approval.grant_preview.source)}` : ""} + {previewStreams.length ? ` · streams ${previewStreams.join(", ")}` : ""} +
    +
    +
    + + + + Review request + + + Deny request + +
    +
    + ); +} diff --git a/apps/console/src/app/(console)/lib/operator-approvals.contract.test.ts b/apps/console/src/app/(console)/lib/operator-approvals.contract.test.ts new file mode 100644 index 000000000..2888c133f --- /dev/null +++ b/apps/console/src/app/(console)/lib/operator-approvals.contract.test.ts @@ -0,0 +1,31 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const HERE = fileURLToPath(new URL(".", import.meta.url)); +const SOURCE_FILE = `${HERE}operator-approvals.ts`; + +const FINAL_APPROVAL_POST_RE = + /fetchAs\("\/consent\/approve"[\s\S]*?body: JSON\.stringify\(\{\s*approval_review_revision: input\.approvalReviewRevision,\s*request_uri: input\.requestUri,\s*\}\)/; +const FINAL_APPROVAL_BODY_RE = + /fetchAs\("\/consent\/approve"[\s\S]*?body: JSON\.stringify\(\{([\s\S]*?)\}\),[\s\S]*?method: "POST"/g; +const MUTABLE_FINAL_APPROVAL_FACT_RE = + /\b(?:subject_id|ai_training_consented|approved_source_indexes|source_narrowing|confirm_reviewed_decision)\b/; +const NO_REVIEW_DURING_PENDING_APPROVAL_RE = + /export async function approvePendingApproval[\s\S]*?fetchAs\("\/consent\/review"/; + +test("console consent approval binds final issuance to PR114 immutable review revision only", async () => { + const src = await readFile(SOURCE_FILE, "utf8"); + + assert.match(src, FINAL_APPROVAL_POST_RE); + const finalApprovalBodies = Array.from(src.matchAll(FINAL_APPROVAL_BODY_RE), (match) => match[1] ?? ""); + assert.equal(finalApprovalBodies.length, 2); + for (const body of finalApprovalBodies) { + assert.doesNotMatch(body, MUTABLE_FINAL_APPROVAL_FACT_RE); + } + assert.doesNotMatch(src, NO_REVIEW_DURING_PENDING_APPROVAL_RE); +}); diff --git a/apps/console/src/app/(console)/lib/operator-approvals.ts b/apps/console/src/app/(console)/lib/operator-approvals.ts index 70361ae59..22f819744 100644 --- a/apps/console/src/app/(console)/lib/operator-approvals.ts +++ b/apps/console/src/app/(console)/lib/operator-approvals.ts @@ -13,6 +13,7 @@ */ import { describeError } from "./describe-error.ts"; import { getAsInternalUrl, ReferenceServerUnreachableError, withOwnerSessionCookie } from "./owner-token.ts"; +import { requireOneClickConsentApproval } from "./pending-consent-review.ts"; function asForm(body: Record): string { return new URLSearchParams(body).toString(); @@ -26,6 +27,37 @@ function readBody(res: Response): Promise { return res.text(); } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function readConsentReview( + body: unknown, + expectedRequestUri?: string +): { approvalReview: Record; batch: boolean; requestUri: string; revision: string } { + if (!isRecord(body)) { + throw new Error("consent review returned a non-object response"); + } + if (!isRecord(body.approval_review)) { + throw new Error("consent review returned without the exact approval artifact"); + } + if (typeof body.approval_review_revision !== "string" || !body.approval_review_revision) { + throw new Error("consent review returned without approval_review_revision"); + } + if (typeof body.request_uri !== "string" || !body.request_uri) { + throw new Error("consent review returned without canonical request_uri"); + } + if (expectedRequestUri && body.request_uri !== expectedRequestUri) { + throw new Error("consent review returned a different request_uri"); + } + return { + approvalReview: body.approval_review, + batch: body.batch === true, + requestUri: body.request_uri, + revision: body.approval_review_revision, + }; +} + async function fetchAs(path: string, init: RequestInit): Promise { try { return await fetch( @@ -51,12 +83,24 @@ async function fetchAs(path: string, init: RequestInit): Promise { * which projects only the opaque `approval_id`. */ export async function approveConsentRequest(requestUri: string, subjectId = "owner_local") { + const reviewResponse = await fetchAs("/consent/review", { + body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewBody = await readBody(reviewResponse); + if (!reviewResponse.ok) { + throw new Error(describeError(reviewBody, `consent review failed (${reviewResponse.status})`)); + } + const review = readConsentReview(reviewBody, requestUri); + requireOneClickConsentApproval(review); + const response = await fetchAs("/consent/approve", { body: JSON.stringify({ - request_uri: requestUri, - subject_id: subjectId, + approval_review_revision: review.revision, + request_uri: review.requestUri, }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); const body = await readBody(response); @@ -84,6 +128,8 @@ export async function denyConsentRequest(requestUri: string) { export async function approvePendingApproval(input: { kind: "consent" | "owner_device"; approvalId: string; + approvalReviewRevision?: string; + requestUri?: string; userCode?: string | null; subjectId?: string; }) { @@ -93,12 +139,15 @@ export async function approvePendingApproval(input: { const subjectId = input.subjectId || "owner_local"; if (input.kind === "consent") { + if (!(input.requestUri && input.approvalReviewRevision)) { + throw new Error("consent approval requires reviewed request_uri and approval_review_revision"); + } const response = await fetchAs("/consent/approve", { body: JSON.stringify({ - approval_id: input.approvalId, - subject_id: subjectId, + approval_review_revision: input.approvalReviewRevision, + request_uri: input.requestUri, }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); const body = await readBody(response); diff --git a/apps/console/src/app/(console)/lib/pending-consent-review.test.ts b/apps/console/src/app/(console)/lib/pending-consent-review.test.ts new file mode 100644 index 000000000..a6ad96366 --- /dev/null +++ b/apps/console/src/app/(console)/lib/pending-consent-review.test.ts @@ -0,0 +1,17 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { requireOneClickConsentApproval } from "./pending-consent-review.ts"; + +const HOSTED_BATCH_REVIEW_ERROR = /Batch approval requires hosted source review/; + +test("single consent may use the console one-click approval flow", () => { + assert.doesNotThrow(() => requireOneClickConsentApproval({ batch: false })); +}); + +test("batch consent never enters the console one-click approval flow", () => { + assert.throws(() => requireOneClickConsentApproval({ batch: true }), HOSTED_BATCH_REVIEW_ERROR); +}); diff --git a/apps/console/src/app/(console)/lib/pending-consent-review.ts b/apps/console/src/app/(console)/lib/pending-consent-review.ts new file mode 100644 index 000000000..151a42662 --- /dev/null +++ b/apps/console/src/app/(console)/lib/pending-consent-review.ts @@ -0,0 +1,12 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +export interface ConsentApprovalReview { + batch: boolean; +} + +export function requireOneClickConsentApproval(review: ConsentApprovalReview): void { + if (review.batch) { + throw new Error("Batch approval requires hosted source review"); + } +} diff --git a/apps/console/src/app/(console)/lib/ref-client.approval-review.contract.test.ts b/apps/console/src/app/(console)/lib/ref-client.approval-review.contract.test.ts new file mode 100644 index 000000000..4261f45f9 --- /dev/null +++ b/apps/console/src/app/(console)/lib/ref-client.approval-review.contract.test.ts @@ -0,0 +1,31 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const HERE = fileURLToPath(new URL(".", import.meta.url)); +const SOURCE_FILE = `${HERE}ref-client.ts`; +const PAGE_FILE = `${HERE}../grants/approvals/[approvalId]/page.tsx`; +const CONSENT_REVIEW_FETCH_RE = /refFetch\("\/consent\/review"[\s\S]*?approval_id: approvalId/; +const ARTIFACT_BINDING_RE = /approval_review: body\.approval_review as ConsentApprovalArtifact/; +const REVISION_BINDING_RE = /approval_review_revision: body\.approval_review_revision/; +const REQUEST_URI_BINDING_RE = /request_uri: body\.request_uri/; +const NO_DETAIL_RECONSTRUCTION_RE = /buildConsentApprovalDetail/; +const ERROR_CLEARS_CONFIRM_RE = /confirm=\{!query\.approval_error && query\.confirm === "1"\}/; + +test("approval review page source materializes PR114 consent review artifact before rendering", async () => { + const src = await readFile(SOURCE_FILE, "utf8"); + assert.match(src, CONSENT_REVIEW_FETCH_RE); + assert.match(src, ARTIFACT_BINDING_RE); + assert.match(src, REVISION_BINDING_RE); + assert.match(src, REQUEST_URI_BINDING_RE); + assert.doesNotMatch(src, NO_DETAIL_RECONSTRUCTION_RE); +}); + +test("approval error clears confirmation before rerendering the materialized review", async () => { + const src = await readFile(PAGE_FILE, "utf8"); + assert.match(src, ERROR_CLEARS_CONFIRM_RE); +}); diff --git a/apps/console/src/app/(console)/lib/ref-client.ts b/apps/console/src/app/(console)/lib/ref-client.ts index 2d1b9af1c..0d5f628d0 100644 --- a/apps/console/src/app/(console)/lib/ref-client.ts +++ b/apps/console/src/app/(console)/lib/ref-client.ts @@ -2463,23 +2463,217 @@ export async function refSearch(query: string): Promise<{ }; } -export interface PendingApproval { +export interface PendingConsentApproval { approval_id: string; + /** Batch requests require the hosted per-source review ceremony. */ + batch: boolean; client_id?: string | null; created_at: string; grant_preview?: { source?: SourceObject | null; streams?: Array<{ name?: string } | string>; } | null; - kind: "consent" | "owner_device"; + kind: "consent"; object: "approval"; user_code?: string | null; } +export interface PendingOwnerDeviceApproval { + approval_id: string; + client_id?: string | null; + created_at: string; + grant_preview?: null; + kind: "owner_device"; + object: "approval"; + user_code?: string | null; +} + +export type PendingApproval = PendingConsentApproval | PendingOwnerDeviceApproval; + export async function listPendingApprovals(): Promise> { return (await refFetch("/_ref/approvals")) as ListResponse; } +export type ApprovalReviewJson = + | boolean + | null + | number + | string + | ApprovalReviewJson[] + | { [key: string]: ApprovalReviewJson }; + +export interface ReviewedStreamArtifact { + fields: string[]; + instance_ids: string[]; + name: string; + resources?: string[]; + time_constraint?: { field: string; since?: string; until?: string }; +} + +export interface ReviewedClientArtifact { + client_display?: { + logo_uri?: string | null; + name?: string | null; + policy_uri?: string | null; + tos_uri?: string | null; + uri?: string | null; + } | null; + client_id: string; + registration_mode: string; +} + +export interface ReviewedSourceArtifact { + id: string; + kind: string; +} + +export interface SourceDeclarationArtifact { + accepted_revision_reference?: string; + digest: string; + publisher_attribution?: { + id: string; + status: "unverified"; + }; + resource_authority?: { status: "local_operator_provisioned" } | { authority_binding: string; status: "verified" }; + version: string; +} + +export interface ReviewClientClaimsArtifact { + commitments: string[]; +} + +export interface SingleConsentApprovalArtifact { + access_mode: string; + ai_training_consented: boolean | null; + client: ReviewedClientArtifact; + client_claims: ReviewClientClaimsArtifact | null; + expires_at: string | null; + purpose_code: string; + purpose_description: string | null; + resolved_streams: ReviewedStreamArtifact[]; + retention: { max_duration?: string; on_expiry?: string } | null; + selection_preset: string | null; + source: ReviewedSourceArtifact; + source_declaration: SourceDeclarationArtifact; + subject: { id: string }; + version: "reference.approval-review.v1"; +} + +export interface BatchConsentApprovalArtifact { + access_mode: string | null; + approved_source_indexes: number[]; + client: ReviewedClientArtifact; + expires_at: string | null; + parent_package_id: string | null; + source_narrowing: Record; + sources: Array<{ + access_mode: string; + client_claims: ReviewClientClaimsArtifact | null; + index: number; + purpose_code: string; + purpose_description: string | null; + resolved_streams: ReviewedStreamArtifact[]; + retention: { max_duration?: string; on_expiry?: string } | null; + selection_preset: string | null; + source: ReviewedSourceArtifact; + source_declaration: SourceDeclarationArtifact; + }>; + subject: { id: string }; + version: "reference.batch-approval-review.v1"; +} + +export type ConsentApprovalArtifact = SingleConsentApprovalArtifact | BatchConsentApprovalArtifact; + +export interface ConsentApprovalReview { + approval_id: string; + approval_review: ConsentApprovalArtifact; + approval_review_revision: string; + batch: boolean; + kind: "consent"; + object: "approval_review"; + request_uri: string; +} + +export interface LegacyConsentApprovalReview { + approval_id: string; + client: { + client_id: string; + display: { name: string | null; policy_uri: string | null; tos_uri: string | null; uri: string | null }; + registration_mode: string; + }; + created_at: string; + expires_at: string; + grant_outcome: { access_mode: string; description: string }; + kind: "consent"; + object: "approval_review"; + purpose: { code: string | null; description: string | null }; + retention: ApprovalReviewJson | null; + source: { id: string; kind: "connector" | "provider_native" } | null; + streams: Array<{ + client_claims: ApprovalReviewJson | null; + connection_id: string | null; + fields: string[] | null; + name: string; + necessity: string | null; + resources: ApprovalReviewJson[] | null; + time_range: { since: string | null } | null; + view: string | null; + }>; + trust: "unverified"; +} + +export interface OwnerDeviceApprovalReview { + approval_id: string; + client_id: string; + created_at: string; + expires_at: string; + kind: "owner_device"; + object: "approval_review"; +} + +export type ApprovalReview = ConsentApprovalReview | OwnerDeviceApprovalReview; + +export async function getPendingApprovalReview(approvalId: string): Promise { + const detail = (await refFetch(`/_ref/approvals/${encodeURIComponent(approvalId)}`)) as + | LegacyConsentApprovalReview + | OwnerDeviceApprovalReview; + if (detail.kind !== "consent") { + return detail; + } + const body = (await refFetch("/consent/review", undefined, { + body: JSON.stringify({ approval_id: approvalId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + })) as { + approval_review?: unknown; + approval_review_revision?: unknown; + batch?: unknown; + request_uri?: unknown; + }; + if ( + typeof body.request_uri !== "string" || + typeof body.approval_review_revision !== "string" || + !body.approval_review || + typeof body.approval_review !== "object" || + Array.isArray(body.approval_review) + ) { + throw new RefRequestError( + "consent review did not return an immutable approval artifact", + 400, + JSON.stringify(body) + ); + } + return { + approval_id: detail.approval_id, + approval_review: body.approval_review as ConsentApprovalArtifact, + approval_review_revision: body.approval_review_revision, + batch: body.batch === true, + kind: "consent", + object: "approval_review", + request_uri: body.request_uri, + }; +} + /** Operator-issued OAuth client (one per dashboard-issued bearer). */ export interface OwnerIssuedClient { active_token_count: number; diff --git a/apps/site/content/docs/index.mdx b/apps/site/content/docs/index.mdx index e88eb29b4..76ca00172 100644 --- a/apps/site/content/docs/index.mdx +++ b/apps/site/content/docs/index.mdx @@ -10,6 +10,7 @@ These pages describe the **protocol**: what PDPP is, what it requires, and what + diff --git a/apps/site/content/docs/meta.json b/apps/site/content/docs/meta.json index 587db571d..4dcfdcf4c 100644 --- a/apps/site/content/docs/meta.json +++ b/apps/site/content/docs/meta.json @@ -4,6 +4,7 @@ "pages": [ "---Specification---", "spec-core", + "spec-discovery-and-trust", "spec-collection-profile", "spec-ext-lexical-search", "spec-ext-aggregation", diff --git a/apps/site/content/docs/reference-implementation-examples.md b/apps/site/content/docs/reference-implementation-examples.md index 0fed67e7c..4e79ef582 100644 --- a/apps/site/content/docs/reference-implementation-examples.md +++ b/apps/site/content/docs/reference-implementation-examples.md @@ -17,7 +17,7 @@ If you want the public explainer and run/deploy posture first, start with [/refe Two boundaries matter when reading them: -- Client requests are staged through `POST /oauth/par`, then approved through the current consent shell at `GET /consent?request_uri=...` and `POST /consent/approve`. +- Client requests are staged through `POST /oauth/par`, reviewed through `POST /consent/review`, then approved through `POST /consent/approve` with the reviewed revision. - Owner self-export is a separate OAuth device flow using `POST /oauth/device_authorization`, `POST /device/approve`, and `POST /oauth/token`. Deliberately out of scope in these examples: a generic third-party authorization-code redirect flow. @@ -26,7 +26,7 @@ The current reference proves request staging, public-client self-registration, c ## Example 1: Longview requests compensation data from Northstar HR -This is the native-provider path. Longview requests compensation records from `Northstar HR`, so the request identifies the source with `source: { kind: "provider_native", id: "northstar_hr" }`. +This is the native-provider path. Longview requests compensation records from `Northstar HR`, so the request identifies the source with `source: { kind: "provider_native", id: "https://northstar.example/sources/hr" }`. ### Step 1: Longview stages the request through PAR @@ -47,7 +47,7 @@ Content-Type: application/json "type": "https://pdpp.dev/data-access", "source": { "kind": "provider_native", - "id": "northstar_hr" + "id": "https://northstar.example/sources/hr" }, "purpose_code": "https://longview.example/purpose/career-move-planning", "purpose_description": "Compare salary, equity, benefits, and tax tradeoffs before a career move", @@ -83,17 +83,32 @@ GET /consent?request_uri=urn%3Apdpp%3Apending-consent%3Adc_4f5f7c0f9b6a4f31 The consent surface is server-rendered. It reads the staged request, shows the client identity and requested streams, and lets the user approve or deny it. -### Step 3: Approval creates the grant and returns the client token +### Step 3: The owner reviews the exact approval artifact + +Before approval, the client or hosted UI finalizes the exact artifact that will be approved. The response contains `approval_review` and `approval_review_revision`. + +```http +POST /consent/review +Content-Type: application/json + +{ + "request_uri": "urn:pdpp:pending-consent:dc_4f5f7c0f9b6a4f31", + "subject_id": "owner_local" +} +``` + +### Step 4: Approval creates the grant and returns the client token The current reference implementation uses a direct approval shortcut instead of a full authorization-code redirect. ```http POST /consent/approve +Accept: application/json Content-Type: application/json { "request_uri": "urn:pdpp:pending-consent:dc_4f5f7c0f9b6a4f31", - "subject_id": "owner_local" + "approval_review_revision": "reference.approval-review.v1:sha256:...", } ``` @@ -113,7 +128,7 @@ Reference response: }, "source": { "kind": "provider_native", - "id": "northstar_hr" + "id": "https://northstar.example/sources/hr" }, "purpose_code": "https://longview.example/purpose/career-move-planning", "access_mode": "continuous", @@ -312,7 +327,7 @@ Reference response for a **valid, active client-scoped token**: }, "source": { "kind": "provider_native", - "id": "northstar_hr" + "id": "https://northstar.example/sources/hr" }, "purpose_code": "https://longview.example/purpose/career-move-planning", "access_mode": "continuous", @@ -426,12 +441,22 @@ curl -sX POST "$AS_URL/oauth/par" \ }' | jq -r .request_uri ``` -### Step 2: Owner approval embeds resources[] in the grant +### Step 2: Owner review freezes resources[] in the approval artifact ```bash -APPROVED=$(curl -sX POST "$AS_URL/consent/approve" \ +REVIEW=$(curl -sX POST "$AS_URL/consent/review" \ -H 'Content-Type: application/json' \ -d "{\"request_uri\": \"$REQUEST_URI\", \"subject_id\": \"owner_local\"}") +REVIEW_REVISION=$(echo "$REVIEW" | jq -r .approval_review_revision) +``` + +### Step 3: Revision-only approval embeds resources[] in the grant + +```bash +APPROVED=$(curl -sX POST "$AS_URL/consent/approve" \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -d "{\"request_uri\": \"$REQUEST_URI\", \"approval_review_revision\": \"$REVIEW_REVISION\"}") TOKEN=$(echo $APPROVED | jq -r .token) ``` @@ -447,7 +472,7 @@ The issued grant embeds `resources` on the stream: } ``` -### Step 3: RS enforces the resources[] list: only those records are visible +### Step 4: RS enforces the resources[] list: only those records are visible ```bash curl -s "$RS_URL/v1/streams/top_artists/records" \ @@ -489,7 +514,7 @@ REQUEST_URI=$(curl -sX POST "$AS_URL/oauth/par" \ "client_id": "longview", "authorization_details": [{ "type": "https://pdpp.dev/data-access", - "source": { "kind": "connector", "id": "spotify" }, + "source": { "kind": "connector", "id": "https://registry.pdpp.dev/connectors/spotify" }, "purpose_code": "https://pdpp.dev/purpose/personalization", "purpose_description": "One-time recommendation bootstrap", "access_mode": "single_use", @@ -498,12 +523,22 @@ REQUEST_URI=$(curl -sX POST "$AS_URL/oauth/par" \ }' | jq -r .request_uri) ``` -### Step 2: Approval issues the first (and only) token +### Step 2: Review the exact single-use approval artifact ```bash -APPROVED=$(curl -sX POST "$AS_URL/consent/approve" \ +REVIEW=$(curl -sX POST "$AS_URL/consent/review" \ -H 'Content-Type: application/json' \ -d "{\"request_uri\": \"$REQUEST_URI\", \"subject_id\": \"owner_local\"}") +REVIEW_REVISION=$(echo "$REVIEW" | jq -r .approval_review_revision) +``` + +### Step 3: Revision-only approval issues the first (and only) token + +```bash +APPROVED=$(curl -sX POST "$AS_URL/consent/approve" \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -d "{\"request_uri\": \"$REQUEST_URI\", \"approval_review_revision\": \"$REVIEW_REVISION\"}") TOKEN=$(echo "$APPROVED" | jq -r .token) ``` @@ -525,7 +560,7 @@ reference default is 24h from issuance: "registration_mode": "pre_registered_public", "client_display": { "name": "Longview" } }, - "source": { "kind": "connector", "id": "spotify" }, + "source": { "kind": "connector", "id": "https://registry.pdpp.dev/connectors/spotify" }, "manifest_version": "1.0.0", "purpose_code": "https://pdpp.dev/purpose/personalization", "purpose_description": "One-time recommendation bootstrap", diff --git a/apps/site/content/docs/reference-implementation.md b/apps/site/content/docs/reference-implementation.md index 656f8a0f0..9734d8664 100644 --- a/apps/site/content/docs/reference-implementation.md +++ b/apps/site/content/docs/reference-implementation.md @@ -81,7 +81,7 @@ Client requests are staged through: - `POST /oauth/par` -The live reference uses PAR to persist the RFC 9396 `authorization_details` request, then sends the user through the reference consent shell. Approval returns the grant and client bearer token directly. That direct-token return is a reference shortcut; it is not a generic OAuth authorization-code redirect profile. +The live reference uses PAR to persist the RFC 9396 `authorization_details` request, then sends the user through the reference consent shell. The owner reviews an exact approval artifact before approval. Approval posts only the `request_uri` and `approval_review_revision`, then returns the grant and client bearer token directly. That direct-token return is a reference shortcut; it is not a generic OAuth authorization-code redirect profile. ### Client registration @@ -104,10 +104,11 @@ The current reference contract expects a single RFC 9396 `authorization_details` The staged request is reviewed through: - `GET /consent?request_uri=...` +- `POST /consent/review` - `POST /consent/approve` - `POST /consent/deny` -Approval returns the issued grant and client bearer token directly (the reference shortcut noted under Client request start). +`POST /consent/review` returns `approval_review` and `approval_review_revision`. `POST /consent/approve` must send that revision. It must not send stream, field, resource, or source choices again. Batch approval also requires `confirm_reviewed_decision`. ### Owner self-export @@ -208,7 +209,7 @@ The reference is trying to prove one specific architectural point: That is why the same engine supports both: -- `source: { kind: "provider_native", id: "northstar_hr" }` for native sources such as Northstar HR +- `source: { kind: "provider_native", id: "https://northstar.example/sources/hr" }` for native sources such as Northstar HR - `source: { kind: "connector", id: "https://registry.pdpp.dev/connectors/spotify" }` for collected/polyfill sources such as Spotify ## What is still intentionally thin diff --git a/apps/site/content/docs/spec-discovery-and-trust.md b/apps/site/content/docs/spec-discovery-and-trust.md new file mode 100644 index 000000000..c06c3ae9d --- /dev/null +++ b/apps/site/content/docs/spec-discovery-and-trust.md @@ -0,0 +1,157 @@ +--- +title: "Discovery and Trust" +description: "How an authorization server discovers, validates, and accepts PDPP source declarations." +--- + + + Status: **Companion specification draft** + + Date: 2026-08-11 + + Scope: Source onboarding, provider-native discovery, bounded retrieval, authority, and accepted declaration revisions. + + +## 1. Scope + +This companion specification defines how an authorization server discovers, +retrieves, validates, and accepts a source declaration. The Core specification +defines the `SourceDeclaration`, selection request, grant, and resource server +semantics. This specification does not redefine those contracts. + +Discovery is an onboarding concern. It is not a resource server authorization +dependency. A resource server enforces a resolved grant without retrieving a +current declaration. + +The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, +RECOMMENDED, NOT RECOMMENDED, MAY, and OPTIONAL in this document are to be +interpreted as described in BCP 14 [RFC 2119] [RFC 8174] when, and only when, +they appear in all capitals. + +## 2. Provider-native discovery + +An authorization server that onboards a provider-native source SHALL start +with an already accepted protected-resource identifier. The identifier MUST be +an HTTPS URI without a fragment or user information. It SHOULD NOT contain a +query component. + +The authorization server SHALL derive the protected-resource metadata URL as +specified by RFC 9728 Section 3.1. It inserts +`/.well-known/oauth-protected-resource` between the authority and any path or +query component. It removes the terminating slash after the authority before +insertion. For example: + +| Protected-resource identifier | Metadata URL | +| --- | --- | +| `https://resource.example.com` | `https://resource.example.com/.well-known/oauth-protected-resource` | +| `https://resource.example.com/` | `https://resource.example.com/.well-known/oauth-protected-resource` | +| `https://resource.example.com/?tenant=one` | `https://resource.example.com/.well-known/oauth-protected-resource?tenant=one` | +| `https://resource.example.com/owner/alice` | `https://resource.example.com/.well-known/oauth-protected-resource/owner/alice` | + +The authorization server SHALL retrieve the metadata with HTTP `GET`. The +returned `resource` value MUST be byte-for-byte identical to the protected- +resource identifier used for the request. + +PDPP defines the protected-resource metadata member +`pdpp_source_declaration_uri`. It contains one HTTPS URI string without a +fragment or user information. The member is OPTIONAL in generic protected- +resource metadata. It is REQUIRED when the resource is onboarded as one +provider-native PDPP source. + +The retrieved `SourceDeclaration.source.kind` MUST be `provider_native`, and +`SourceDeclaration.source.id` MUST be identical to the accepted +protected-resource identifier. The authorization server SHALL reject either +mismatch before consent or grant issuance. + +## 3. Source onboarding and authority + +An ordinary authorization request SHALL name only a source already accepted by +the authorization server. A new provider-native resource SHALL enter through +explicit owner or operator onboarding. A client SHALL NOT select a new +resource authority or declaration URI during authorization. + +Connector and community sources SHALL enter through an installed catalog, an +accepted registry entry, or explicit local provisioning. Local provisioning +MAY allow private or local endpoints under the operator's network policy. This +local exception does not change the public protocol requirements. + +TLS authentication of protected-resource metadata authenticates the resource +authority and its declaration pointer. The declaration MAY be hosted on a +different origin. The declaration host does not, by itself, authenticate +`publisher.id`. + +The authorization server SHALL keep resource authority separate from publisher +attribution. It SHALL treat `publisher.id` as authenticated only when an +accepted channel or configured mapping binds that publisher to the declaration. +Without that binding, the publisher value is a non-authoritative claim and +MUST NOT support source acceptance, redirect policy, attribution, or another +trust decision. + +## 4. Bounded declaration retrieval + +The declaration retriever SHALL: + +1. Use HTTPS without ambient credentials. +2. Enforce configured response-byte, time, and retrieval-depth limits. +3. Require every redirect target and the final declaration URL to satisfy the + accepted declaration pointer and the configured redirect policy. The policy + MAY reject all redirects. +4. Resolve DNS freshly for every connection attempt, including each redirect + hop. +5. Validate every resolved address against the applicable network policy before + connecting. +6. Connect only to an address from that validated result while preserving the + destination authority for TLS authentication. +7. Reject a declaration that requires automatic retrieval of a remote schema. +8. Fail closed when a bound, validation, redirect, network, or identity check + fails. + +An address accepted for an earlier connection attempt MUST NOT authorize a +later DNS result. Validation of the final declaration URL is separate from +validation of `SourceDeclaration.source.id`. The declaration location is not +the source identity. + +## 5. Accepted revisions + +An accepted revision SHALL be keyed by its accepted authority binding, +`source.id`, and opaque `declaration_version`. After JSON parsing and Source +Declaration validation, later content under the same key MUST compare equal as +parsed JSON. + +An implementation MAY use an internal content fingerprint to accelerate this +comparison. The fingerprint algorithm is not a protocol identity and need not +be portable between implementations. + +When the authorization server uses provider-native discovery for consent, its +consent and audit evidence SHALL retain an unambiguous AS-local +accepted-revision reference to the accepted authority binding and parsed +revision retained by this AS. That reference is not a portable authorization +right, grant identity, bearer handle, or cross-AS declaration credential. + +Different parsed content under an accepted key is equivocation. The +authorization server SHALL reject it and retain the previously accepted +content. It SHALL NOT infer ordering or freshness from +`declaration_version`. A pointer to a previously accepted revision is accepted +or rejected only under explicit publisher or local rollback policy. + +## 6. Use and lifecycle + +Declaration display values are untrusted input. An implementation SHALL escape +them for their output context and enforce configured response, parser, display, +and logging limits before consent rendering or logging. + +Current declaration query capabilities MUST NOT widen an issued grant. A local +block MAY prevent a declaration from being used for new consent. That block +MUST NOT automatically revoke historical grants. + +The Collection Profile remains OPTIONAL. Discovery and trust apply equally to +provider-native sources, pre-collected sources, and connector-backed sources. +An accepted source does not need a Collection Profile extension unless the +implementation uses Collection Profile behavior for that source. + +## References + +- RFC 2119, Key words for use in RFCs to Indicate Requirement Levels +- RFC 8174, Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words +- RFC 9728, OAuth 2.0 Protected Resource Metadata +- [PDPP Core](spec-core) +- [PDPP Collection Profile](spec-collection-profile) diff --git a/apps/site/scripts/sync-spec-docs.mjs b/apps/site/scripts/sync-spec-docs.mjs index 3f53384c7..250db968d 100644 --- a/apps/site/scripts/sync-spec-docs.mjs +++ b/apps/site/scripts/sync-spec-docs.mjs @@ -40,6 +40,7 @@ const SPECS = [ "spec-core", "spec-data-query-api", "spec-deferred", + "spec-discovery-and-trust", ]; // Root header shape (uniform across all spec files): // line 1: `# ` diff --git a/apps/site/scripts/sync-spec-docs.mts b/apps/site/scripts/sync-spec-docs.mts index a0f6c9051..c28ed6e27 100644 --- a/apps/site/scripts/sync-spec-docs.mts +++ b/apps/site/scripts/sync-spec-docs.mts @@ -44,6 +44,7 @@ const SPECS = [ "spec-core", "spec-data-query-api", "spec-deferred", + "spec-discovery-and-trust", ]; // Root header shape (uniform across all spec files): diff --git a/apps/site/spec-headers/spec-discovery-and-trust.header.md b/apps/site/spec-headers/spec-discovery-and-trust.header.md new file mode 100644 index 000000000..01160e699 --- /dev/null +++ b/apps/site/spec-headers/spec-discovery-and-trust.header.md @@ -0,0 +1,12 @@ +--- +title: "Discovery and Trust" +description: "How an authorization server discovers, validates, and accepts PDPP source declarations." +--- + +<Callout type="info" title="Spec status"> + Status: **Companion specification draft** + + Date: 2026-08-11 + + Scope: Source onboarding, provider-native discovery, bounded retrieval, authority, and accepted declaration revisions. +</Callout> diff --git a/apps/site/src/lib/spec-nav-slugs.ts b/apps/site/src/lib/spec-nav-slugs.ts index 886dc9d66..d7a96412e 100644 --- a/apps/site/src/lib/spec-nav-slugs.ts +++ b/apps/site/src/lib/spec-nav-slugs.ts @@ -14,8 +14,8 @@ // still resolves. export const docsRoute = "/specification"; -// The specification IS the page. The rail lists the normative core, the profile -// that accompanies it, and the three extension profiles — five documents, the +// The specification IS the page. The rail lists the normative core, its two +// companion specifications, and the three extension profiles: six documents, the // whole of what the protocol normatively defines. Everything else the repository // carries (guides, design rationale, deferred concerns, open questions, the // superseded Data Query API) is still built, still routed, still linked, and @@ -26,6 +26,7 @@ export const docsRoute = "/specification"; // Order is the reading order of the specification set, not alphabetical. export const PRIMARY_SLUGS = [ "spec-core", + "spec-discovery-and-trust", "spec-collection-profile", "spec-ext-lexical-search", "spec-ext-aggregation", diff --git a/design-notes/seam-spike/corpus.md b/design-notes/seam-spike/corpus.md new file mode 100644 index 000000000..5b19ca123 --- /dev/null +++ b/design-notes/seam-spike/corpus.md @@ -0,0 +1,486 @@ +# PR89 authorization seam execution authority + +Status: executable spike specification +Owner: reference implementation owner +Updated: 2026-08-11 + +## Purpose and decision boundary + +This is the sole execution definition for PR89. PR89 executes the spike and +the implementation it exercises. + +The spike can decide only: + +1. whether authorization semantics separate cleanly from the OAuth/RAR binding; +2. the binding-neutral resolved shape named `ApprovedAuthorization`; and +3. whether binding and lifecycle facts compose into a resource-server + authorization context. + +Remaining proposed common schemas stay undecided. GNAP is a pure feasibility +map and is non-gating. OAuth/RAR is the only implemented binding in this PR. + +## ApprovedAuthorization contract + +`ApprovedAuthorization` consumes the Source contract exactly. Its equality +contains no provenance outside that contract. The canonical value is: + +```json +{ + "source_id": "https://sources.example/records/spotify", + "access_mode": "single_use", + "streams": [ + { + "name": "top_artists", + "instance_ids": ["account-a"], + "fields": ["id", "name"], + "time_constraint": { + "field": "played_at", + "since": "2026-01-01T00:00:00Z", + "until": "2026-04-01T00:00:00Z" + }, + "resources": ["artist:42"] + } + ] +} +``` + +`source_id` and `access_mode` are nonempty strings. `access_mode` is +`single_use` or `continuous`. `streams` is nonempty. Every stream has a +nonempty `name`, unique within the authorization, nonempty unique +`instance_ids`, and nonempty unique `fields`. `time_constraint` is optional; +when present, `field` is nonempty and frozen, `since` is an optional lower +bound, `until` is an optional upper bound, and at least one bound is required. +The bounds are compared as received by the Source contract. This spike does +not define a timestamp or duration canonicalization profile. `resources` is +optional and, when present, is a nonempty unique list of canonical resource +identifiers. + +`source.kind` is provenance. It may occur in input fixtures and receipts, but +it is outside `ApprovedAuthorization` equality. The binding must still reject +a kind that does not metadata-match the retained declaration before it derives +the neutral value. + +`ApprovedAuthorization` is the RS enforcement projection, not the whole +consent record. The granted RFC 9396 detail also preserves the Source-defined +`purpose_code`, optional `purpose_description`, optional `retention`, and any +approved selection provenance. Consent evidence retains attributed client +claims and the declaration snapshot. Case 2 verifies that the OAuth carrier +does not discard these approved policy terms even though they are outside +enforcement equality. + +Unknown right-bearing members, empty values, duplicate values, missing +`instance_ids`, missing `fields`, a frozen-field mutation, malformed bounds, +and a widening mutation fail closed. Binding-only fields such as issuer, +audience, token kind, proof, key confirmation, client, subject, grant +identity, cache state, consent evidence, and credential-family state live in a +separate resolved context. + +The separate value is `ResolvedAuthorizationContext`. It composes one +`ApprovedAuthorization` with issuer, exact audience, active and expiry state, +client and subject identity, grant identity, lifecycle and cache state, and +binding-owned presentation evidence. The OAuth/RAR resolver constructs it from +the authenticated introspection response. The RS consumes this context and +never reconstructs `ApprovedAuthorization` from token syntax or an in-process +AS call. + +## Fixed execution environment + +All fixtures are repository-relative and local: + +```text +reference-implementation/test/seam-spike/fixtures/pr89/source.json +reference-implementation/test/seam-spike/fixtures/pr89/grant-v01.json +reference-implementation/test/seam-spike/fixtures/pr89/rar-request.json +reference-implementation/test/seam-spike/fixtures/pr89/rar-request-invalid.json +reference-implementation/test/seam-spike/fixtures/pr89/rar-approved.json +reference-implementation/test/seam-spike/fixtures/pr89/records.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/valid.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-credentials.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-issuer.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-audience.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/expired.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/stale-cache.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/inactive.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-context-kind.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/client-mismatch.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/subject-mismatch.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/source-mismatch.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/grant-mismatch.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/rights-missing.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/instance-mismatch.json +reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/field-mismatch.json +reference-implementation/test/seam-spike/fixtures/pr89/legacy-grant-v01.bytes +reference-implementation/test/seam-spike/fixtures/pr89/gnap/approved.json +reference-implementation/test/seam-spike/fixtures/pr89/gnap/partial.json +reference-implementation/test/seam-spike/fixtures/pr89/gnap/unknown-mandatory.json +``` + +The fixed fixture clock is `2026-08-11T12:00:00Z`. The AS and RS run on +separate ephemeral local ports. The OAuth client registers locally for each +run. The RS introspection client uses the fixed test-only client +`pr89-rs-test` with fixture secret `pr89-rs-test-secret`. The spike uses +authenticated RFC 7662 HTTP introspection with those test credentials. Long-term +registration and discovery belong to the discovery and trust change. PR89 +does not specify an alternative client registration mechanism. + +The RS must call the AS introspection endpoint over HTTP. It must enforce from +the captured response and must not use an in-process introspection fallback or +make a second AS lookup. DPoP text is conditional only: under RFC 9449, the RS +validates request-specific proof and the AS supplies token status and key +confirmation through introspection. This spike does not add nonce or `jti` +policy and does not implement DPoP. + +PostgreSQL is mandatory for the authorization-code and `single_use` race +tests. SQLite may be used for quick parser and non-race checks, but a receipt +cannot pass unless the PostgreSQL race job passes. + +## Stable failure codes + +The parser and resolver use these exact codes, serialized as strings: + +```text +auth.source_id_empty +auth.access_mode_invalid +auth.streams_empty +auth.stream_name_empty +auth.stream_name_duplicate +auth.instance_ids_empty +auth.instance_id_empty +auth.instance_id_duplicate +auth.fields_empty +auth.field_empty +auth.field_duplicate +auth.time_constraint_invalid +auth.time_field_changed +auth.resources_empty +auth.resource_duplicate +auth.unknown_member +auth.widened +source.authorization_details_invalid +context.authentication_failed +context.issuer_mismatch +context.audience_mismatch +context.expired +context.cache_stale +context.active_false +context.kind_mismatch +context.instance_mismatch +context.field_mismatch +context.source_mismatch +context.identity_mismatch +context.grant_mismatch +context.rights_missing +context.rights_duplicated +context.stream_not_allowed +context.field_not_granted +oauth.invalid_grant +oauth.invalid_authorization_details +oauth.single_use_race +authorization_state.unsupported_legacy_shape +gnap.unknown_mandatory_member +``` + +## Seven executable cases + +### Case 1: Source contract and neutral equality + +Fixtures: `source.json`, `grant-v01.json`, and `rar-approved.json`. + +Parse the persisted grant and approved RAR details into +`ApprovedAuthorization`. `rar-approved.json` is the approved baseline. The +fixture contains two streams, distinct instance IDs, a field projection, a +frozen temporal field with both bounds, and a resource allowlist. Include +connector and provider-native provenance variants. Every widening mutation is +compared with that baseline, not with an independently inferred value. + +Oracle: + +- the persisted grant and RAR values are deeply equal after both inputs pass + declaration metadata matching and provenance-only `source.kind` is removed; +- changing issuer, audience, client, subject, or grant identity does not + change equality; +- each invalid fixture returns one of the stable `auth.*` codes; +- instance and field rows are both present in the expected value for every + compound stream. + +### Case 2: Partial approval in the real token response + +Fixtures: `rar-request.json`, `rar-request-invalid.json`, and +`rar-approved.json`. Run the real +authorization-code flow with required `top_artists` and optional +`recently_played`, approving only `top_artists` with the valid PKCE verifier. + +Oracle: HTTP 200 token response, exactly one granted +`authorization_details` member, only the required stream, and matching +neutral values from the stored grant and token response. The declined stream +is absent and cannot be queried. The granted detail preserves approved purpose, +retention, and selection provenance. A missing or malformed approval returns +`oauth.invalid_grant`. An invalid initial Source selection produces +`source.authorization_details_invalid` at the binding-neutral seam and the +OAuth response returns RFC 9396 `invalid_authorization_details`, recorded as +`oauth.invalid_authorization_details`. + +Every successful token-bearing `/oauth/token` response MUST include +`Cache-Control: no-store` and `Pragma: no-cache`. The route-level matrix covers +authorization-code, refresh-token, and device-code exchanges, including grant +and package variants; token errors and unsupported grants are not token-success +responses. + +### Case 3: Authenticated AS to RS context resolution + +Fixtures: `source.json`, `rar-request.json`, `introspection/valid.json`, and the +table-driven mutation fixtures under `introspection/mutations/`. Enter through +the RS. It calls authenticated RFC 7662 introspection using the fixed local +confidential-RS credentials and the exact RS audience. + +Test the exact mutation fixtures `wrong-credentials.json`, `wrong-issuer.json`, +`wrong-audience.json`, `expired.json`, `stale-cache.json`, `inactive.json`, +`wrong-context-kind.json`, `client-mismatch.json`, `subject-mismatch.json`, +`source-mismatch.json`, `grant-mismatch.json`, `rights-missing.json`, +`instance-mismatch.json`, and `field-mismatch.json`. Expiration and instance +mismatches are required rows, not optional extensions. + +Oracle: the valid response returns HTTP 200 and resolves. Wrong credentials +return HTTP 401 with `context.authentication_failed`. Every authenticated but +invalid response returns HTTP 200 with `active: false` and the matching stable +`context.*` reason, including `context.audience_mismatch` for wrong audience. +The RS rejects before the route handler. It makes one introspection request, +performs no in-process lookup, and obtains the complete approved rights from +the response. + +### Case 4: Response-only RS enforcement + +Fixtures: `source.json`, `rar-request.json`, and `records.json`. Capture the +live introspection response, disable the AS endpoint, and use only the decoded +context. Test an allowed stream, allowed instance, allowed field, in-range +record, allowed resource, ungranted stream, wrong instance, ungranted field, +out-of-range record, and a record outside the resource allowlist. + +Oracle: allowed requests succeed. Wrong instances return +`context.instance_mismatch`, ungranted streams return +`context.stream_not_allowed`, ungranted fields return +`context.field_not_granted`, and out-of-range or non-allowlisted records are +omitted without revealing whether an unauthorized record exists. The captured +response is the only authorization input. Instance and temporal field rows are +asserted in the response-derived context. + +### Case 5: Exactly-once authorization-code and single-use races + +Run only against the isolated PostgreSQL test database. Race two redemptions +of one valid authorization code using the same valid PKCE verifier. Race two +issuance attempts for one `single_use` grant. Also redeem the code +sequentially after the first success. + +Oracle: the code race has exactly one success and one `oauth.invalid_grant`; +the single-use race has exactly one success and one `oauth.single_use_race`. +PostgreSQL contains one authorization-code +consumption and one token row. The sequential reuse is denied. Revocation of +an already-issued token after detected code reuse is a separately reported +RFC 6749 SHOULD-strength hardening result and is not a seam pass unless exact +token linkage is implemented and tested. + +The same case runs the refresh tests. It asserts one rotation winner, followed +by `revoked` status for every family row when the losing request presents the +now-superseded generation. No active successor remains after that detected +reuse. Ordinary replay and lost-response retry return `invalid_grant` and the +fresh-authorization-required marker. The initial bearer and every +refresh-derived grant or package bearer persist the family id and a short +token-specific expiry. After replay, every linked bearer row is revoked and +authenticated introspection reports each bearer inactive. Race tests leave no +active linked bearer. Fault injection proves refresh-family and bearer +containment commit or roll back together in SQLite and PostgreSQL. The case +also injects a failure after bearer insertion but before refresh supersession +and proves that no orphan bearer commits. Its migration oracle loads a live +legacy family with no bearer linkage and proves that bootstrap revokes the +family and its grant- or package-bound bearers instead of guessing a backfill. +The case also proves that `single_use` grants receive no refresh token, that +packages are eligible only when every child is `continuous`, that `expires_in` +reflects the persisted access expiry, and that absent `expires_in` and RFC 7662 +`exp` are omitted. These results receive separate receipt fields and do not +change any of the seven seam decisions. + +### Case 6: Breaking authorization-state boundary + +Fixture: `legacy-grant-v01.bytes`. Load the existing pre-contract grant bytes +through the same persisted-grant reader used by the current binding. + +Oracle: the reader returns `authorization_state.unsupported_legacy_shape` +before introspection or route handling. It does not infer `instance_ids`, +issuer, audience, source identity, or any other missing fact from current +configuration. There is no legacy acceptance flag or compatibility adapter. +Fresh consent is required. + +### Case 7: GNAP feasibility and control map + +Fixtures: `gnap/approved.json`, `gnap/partial.json`, and +`gnap/unknown-mandatory.json`. Purely map the neutral rights to one typed GNAP +`access` object and parse it back. + +Oracle: full and narrowed rights round-trip deeply; partial approval is +unambiguous; an unknown mandatory member returns +`gnap.unknown_mandatory_member`. The control map labels each item as +`mapped`, `GNAP-native but binding-owned`, or `not demonstrated`. No control +marked `not demonstrated` counts as passed. GNAP is non-gating. + +## Refresh behavior + +PR89 records the concrete refresh contract for the implementation target. A +refresh-family store row contains `family_id`, `generation`, `token_hash`, +`status` (`active`, `superseded`, or `revoked`), `parent_generation`, `created_at`, and +`superseded_at`. Rotation atomically marks the presented active generation +superseded and inserts exactly one next generation. Any reuse of a superseded +generation, including a retry after a successful rotation whose response was +lost, atomically revokes every row in the family and every access token linked +to that family, returns `invalid_grant`, and requires fresh authorization. The +response is intentionally indistinguishable for all superseded-generation +reuse. Every family bearer has a ten-minute token-specific expiry capped by +the family expiry. Refresh is available only to continuous grants and packages +whose children are all continuous. Tests cover concurrent rotation, retry +after lost response, family-wide refresh and bearer revocation, grant and +package introspection, atomic rollback, truthful lifetime fields, and fresh +authorization requirement. Existing families without a persisted bearer link +are incompatible state: migration revokes the family and its bound bearers and +requires fresh authorization rather than reconstructing linkage. This follows +RFC 9700, RFC 6749, and RFC 7662. + +## Durable post-approval handoff + +The HTML consent surface stores a hash of its bounded exchange code and a +reference to the existing `tokens.token_id` authority. It does not persist a +second plaintext bearer. The code survives process restart. Redemption is +atomic and response-loss idempotent while the referenced grant or package and +token remain active. Revocation or expiry fails closed. Case 5's implementation +inputs and the relevant-file tree cover the handoff schema, SQLite and +PostgreSQL paths, route, and focused restart, concurrency, package, and +revocation tests. + +## Deferred questions + +Keyless recovery and a minimum security-profile floor are deferred questions, +not current implementation or normative scope. They are nonblocking because +the spike has no recovery flow and no profile registry to test. A future +decision on recovery authority and profile vocabulary unlocks them. They must +not count as seam passes. DPoP cryptography and production cache timing are +also outside this spike and are reported separately. + +## Receipt contract + +The strict target command writes a receipt to +`reference-implementation/test/seam-spike/artifacts/pr89-receipt.json`. +The receipt and per-case evidence files are generated artifacts. They are +excluded from the relevant-file tree digest. That digest covers the exact test +files, fixtures, tested implementation inputs, receipt tools, execution +authority, workflow, package metadata, and lockfile listed by the runner. It is +not a self-referential commit or source revision. + +Each case runs in a separate `node:test` process with the structured accounting +reporter. The runner records the exact passing terminal test events. Cases 1 +through 4 must also write a fresh case output to the absolute path supplied in +`PDPP_PR89_CASE_OUTPUT_PATH`. The exact output contract is: + +```json +{ + "schema": "pdpp.pr89.case-output.v1", + "case_id": "case-3", + "oracle_code": "context_resolved", + "observations": ["authenticated_http_introspection"], + "response_envelopes": [{ "name": "valid", "status": 200 }] +} +``` + +The runner requires the closed observation set for that case, not the single +illustrative row above. Observations must be sorted and unique. Cases 2 through +4 must include nonempty, stable, secret-free response projections. Outputs must +not include access tokens, refresh tokens, authorization headers, credentials, +dynamic local ports, or other secrets. Missing test files, fixtures, terminal +events, or required case outputs fail before receipt generation. + +Required schema: + +```json +{ + "schema": "pdpp.pr89.receipt.v2", + "command": "pnpm --filter pdpp-reference-implementation test:seam:pr89 -- --backend postgresql", + "clock": "2026-08-11T12:00:00Z", + "backend": "postgresql", + "relevant_file_tree_digest": "sha256:...", + "fixtures_digest": "sha256:...", + "implementation_inputs_digest": "sha256:...", + "evidence_tree_digest": "sha256:...", + "response_envelopes_digest": "sha256:...", + "cases": { + "case-1": { + "status": "pass", + "oracle_code": "equal", + "case_output_digest": "sha256:...", + "evidence_digest": "sha256:...", + "fixtures_digest": "sha256:...", + "implementation_inputs_digest": "sha256:...", + "terminal_events_digest": "sha256:...", + "test_file_digest": "sha256:..." + } + }, + "assertions": { + "authenticated_http_introspection": true, + "response_only_enforcement": true, + "no_in_process_fallback": true, + "postgresql_races": true, + "legacy_refresh_state_rejected": true, + "refresh_family_access_tokens_inactive_on_replay": true, + "refresh_family_revoked_on_replay": true, + "fresh_authorization_required": true + }, + "decisions": { + "binding_separation": "pass", + "approved_authorization_shape": "pass", + "authorization_context_composition": "pass" + }, + "undecided_common_schemas": true, + "hardening": { + "refresh_rotation": "pass", + "code_reuse_revocation": "separately_reported", + "dpop": "not_demonstrated", + "keyless_recovery": "deferred", + "security_profile_floor": "deferred" + } +} +``` + +The CI job `pr89-seam-receipt` runs the receipt checker with no network access. +The checker rebuilds the complete receipt from all eight canonical evidence +files and current repository inputs. It requires the exact case keys, passing +status, oracle codes, PostgreSQL assertion, three decision keys, and +`undecided_common_schemas: true`. It recomputes every receipt, fixture, +implementation-input, test-file, test-event, case-output, evidence-tree, and +response-envelope digest. It rejects missing evidence, stale inputs, +duplicated approved rights in supplementary context, in-process fallback +markers, secret-bearing response projections, and invented passes for deferred +controls. CI runs real PostgreSQL cases, generates the receipt, validates it, +and fails if either execution or validation fails. + +## Commands and ownership + +Required commands are: + +```bash +openspec validate harden-pdpp-authorization-and-0-1-migration --strict +openspec validate --all --strict +pnpm --filter pdpp-reference-implementation test:seam:pr89 -- --backend postgresql +pnpm --filter pdpp-reference-implementation test:seam:pr89:receipt +pnpm --filter pdpp-reference-implementation test -- test/source-kind-resolution-oracle.test.ts test/as-operations.test.ts +git diff --check +``` + +The five-change ownership and merge order is: + +| Change | Owns | Merge order | +| --- | --- | --- | +| `define-source-declarations-and-resolved-grants` contract | neutral declaration, request, snapshot, and resolved grant contracts | 1 | +| `define-source-declaration-discovery-and-trust` contract | discovery metadata, retrieval, revision, and trust contracts | 2 | +| Source reference implementation | consent snapshots, closed resolved grants, and Source enforcement | 3 | +| Discovery trust reference implementation | discovery storage and accepted-revision consent bridge | 4 | +| `harden-pdpp-authorization-and-0-1-migration` | OAuth/RAR carrier, separated RS, lifecycle and migration gates, durable handoff, and receipts | 5 | + +The hardening change consumes the four preceding Source and discovery layers +and must not define a second grant schema. GNAP and DPoP future work is outside +this program. diff --git a/design-notes/seam-spike/gnap-leg-gating-memo.md b/design-notes/seam-spike/gnap-leg-gating-memo.md new file mode 100644 index 000000000..0aca1d5c9 --- /dev/null +++ b/design-notes/seam-spike/gnap-leg-gating-memo.md @@ -0,0 +1,17 @@ +# GNAP decision record + +Status: pointer + +`design-notes/seam-spike/corpus.md` is the sole execution definition. Its Case +7 is a pure GNAP feasibility and control mapping. GNAP is non-gating for the +PR89 OAuth/RAR seam decision. + +PR89 does not implement a GNAP binding or claim GNAP conformance. The map must +round-trip the Source-defined `ApprovedAuthorization` rights, represent a +narrowed approval unambiguously, and reject an unknown mandatory member. Its +control table may classify a capability as `mapped`, `GNAP-native but +binding-owned`, or `not demonstrated`. `not demonstrated` is never a pass. + +The map does not define a second grant schema. It consumes the Source contract +with `source_id`, `access_mode`, and stream `name`, `instance_ids`, `fields`, +optional frozen-field `time_constraint`, and optional canonical `resources`. diff --git a/docs/agent-skills/pdpp-data-access/references/grant-design.md b/docs/agent-skills/pdpp-data-access/references/grant-design.md index fe701b0c3..51f884e3e 100644 --- a/docs/agent-skills/pdpp-data-access/references/grant-design.md +++ b/docs/agent-skills/pdpp-data-access/references/grant-design.md @@ -9,13 +9,13 @@ one `authorization_details[]` entry per PAR request, and remains the default agent workflow: one source, one request, one grant. The reference also ships a **reference-experimental** batch path that stages several source-bounded entries in one ceremony, plus parent-linked add-source ceremonies that may stage exactly -one added source — see "Reference-experimental batch consent" below. Parentless +one added source - see "Reference-experimental batch consent" below. Parentless single-entry requests still use the default path. One entry has: | Field | Meaning | Common values | | --- | --- | --- | | `type` | Grant family | `"https://pdpp.dev/data-access"` for read access | -| `source` | Which source | `{ "kind": "connector", "id": "https://registry.pdpp.dev/connectors/github" }` or `{ "kind": "provider_native", "id": "northstar_hr" }` | +| `source` | Which source | `{ "kind": "connector", "id": "https://registry.pdpp.dev/connectors/github" }` or `{ "kind": "provider_native", "id": "https://northstar.example/sources/hr" }` | | `purpose_code` | Coarse intent | `assist.summarize`, `assist.review`, `assist.search`, `assist.draft`, `assist.export` | | `purpose_description` | Owner-readable why | One sentence, plain English, scoped to the task | | `access_mode` | Access pattern | `single_use`, `continuous` | @@ -28,7 +28,7 @@ Set exactly one source object. The reference will reject legacy top-level `conne ### Source -- Use the *narrowest* source that contains the data. If both `gmail` and a generic `mail` connector exist, prefer the specific one — its manifest is usually tighter. +- Use the *narrowest* source that contains the data. If both `gmail` and a generic `mail` connector exist, prefer the specific one - its manifest is usually tighter. - A "search across all my data" intent is almost never legitimate as one grant. Split the task by source. - Older docs may call connector sources `connector_id` and native sources `provider_id`; those names now map to `source.id` under the matching `source.kind`. @@ -36,11 +36,11 @@ Set exactly one source object. The reference will reject legacy top-level `conne Stable, machine-readable. The reference accepts any string today, but you should pick from the assistant-task family so the consent UI can group them sensibly: -- `assist.summarize` — produce a digest the user reads. -- `assist.review` — flag/triage items for the user. -- `assist.search` — find specific items the user named. -- `assist.draft` — produce content the user will edit and send. -- `assist.export` — copy data into a user-owned destination they will use elsewhere. +- `assist.summarize` - produce a digest the user reads. +- `assist.review` - flag/triage items for the user. +- `assist.search` - find specific items the user named. +- `assist.draft` - produce content the user will edit and send. +- `assist.export` - copy data into a user-owned destination they will use elsewhere. Avoid `assist.train`, `assist.export.third_party`, `assist.improve_model` etc. They imply retention or third-party flow that this skill does not support and that the consent UI cannot honestly approve. @@ -85,34 +85,41 @@ PAR=$(curl -sX POST $AS_URL/oauth/par \ }') REQUEST_URI=$(echo $PAR | jq -r .request_uri) -# 2. Owner approves — this creates the grant AND issues the first (and only) token. -# The grant is marked consumed atomically. -APPROVED=$(curl -sX POST $AS_URL/consent/approve \ +# 2. Owner reviews the exact artifact to approve. +REVIEW=$(curl -sX POST $AS_URL/consent/review \ -H 'Content-Type: application/json' \ -d "{\"request_uri\": \"$REQUEST_URI\", \"subject_id\": \"owner_local\"}") +REVIEW_REVISION=$(echo $REVIEW | jq -r .approval_review_revision) + +# 3. Owner approves by revision only. This creates the grant AND issues the first +# (and only) token. The grant is marked consumed atomically. +APPROVED=$(curl -sX POST $AS_URL/consent/approve \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -d "{\"request_uri\": \"$REQUEST_URI\", \"approval_review_revision\": \"$REVIEW_REVISION\"}") TOKEN=$(echo $APPROVED | jq -r .token) GRANT_ID=$(echo $APPROVED | jq -r .grant.grant_id) -# 3. First RS query succeeds — the issued token is valid until expiry. +# 4. First RS query succeeds - the issued token is valid until expiry. curl -s "$RS_URL/v1/streams/top_artists/records?limit=1" \ -H "Authorization: Bearer $TOKEN" # → HTTP 200 { "data": [...], ... } -# 4. The grant is now consumed. Introspection confirms active=true (token valid) +# 5. The grant is now consumed. Introspection confirms active=true (token valid) # but a second token issuance attempt for the same grant_id is rejected. # The reference implementation enforces this at the AS layer: any call to # issueToken() with a consumed grant_id throws { code: "grant_consumed" }. # In the standard device-code or PKCE token exchange, the AS returns: # HTTP 400 { "error": "invalid_grant", "error_description": "Grant has already been consumed" } -# 5. Continuous grants are NOT consumed — repeated token issuances succeed. +# 6. Continuous grants are NOT consumed - repeated token issuances succeed. # Run the same flow with "access_mode": "continuous" and the second issuance # returns a fresh token instead of 400. ``` -**What the enforcement looks like:** `POST /consent/approve` calls `issueToken()` internally. +**What the enforcement looks like:** `POST /consent/review` returns the exact artifact and revision. `POST /consent/approve` accepts the revision, not stream or field choices, then calls `issueToken()` internally. `issueToken()` runs an atomic `SELECT … FOR UPDATE` / `UPDATE grants SET consumed = TRUE` in a -single transaction — the check and the mark are one unit. A concurrent second call races on the +single transaction - the check and the mark are one unit. A concurrent second call races on the same row and loses; it reads `consumed = 1` and throws `grant_consumed` before any token row is written. The HTTP boundary surfaces this as `invalid_grant` (RFC 6749 §5.2) with `error_description: "Grant has already been consumed"`. @@ -129,8 +136,8 @@ If you need a relationship (e.g., Gmail messages with message bodies), prefer th ### What *not* to put in the grant -- `client_secret` — you are using `token_endpoint_auth_method: "none"` for public clients; there is no secret. -- Owner email, owner subject id, or any owner identifier — the AS resolves the owner from the session. +- `client_secret` - you are using `token_endpoint_auth_method: "none"` for public clients; there is no secret. +- Owner email, owner subject id, or any owner identifier - the AS resolves the owner from the session. - Free-form retention policies (`"keep_for_days": 90`). The reference does not honor them today; including them gives a false sense of control. If the user wants retention, that's a project-side rule, not a grant field. ## Patterns @@ -167,7 +174,7 @@ Grant A: source={kind: connector, id: https://registry.pdpp.dev/connectors/gmail Grant B: source={kind: connector, id: https://registry.pdpp.dev/connectors/ical}, streams=[events], time_range=next 24h ``` -Don't try to bundle these into one `authorization_details[]` array entry — the reference treats one entry as one source binding. (If you genuinely need several sources set up in one owner sitting, see the reference-experimental batch path below; it still issues one independent grant per source.) +Don't try to bundle these into one `authorization_details[]` array entry - the reference treats one entry as one source binding. (If you genuinely need several sources set up in one owner sitting, see the reference-experimental batch path below; it still issues one independent grant per source.) ### Reference-experimental batch consent @@ -199,8 +206,9 @@ What the owner ceremony does, and what you get back: - **One ceremony, per-source review.** The owner sees one review card per source plus a cumulative-risk header (sensitive-source, continuous-access, no-time-bound, no-field-projection, and total-stream counts across the batch). - **Per-source decisions.** The owner can approve, deny, defer, or narrow each source independently. Approving a subset issues grants for only the approved sources. The owner can narrow a source (drop streams, reduce fields, tighten a time range); you cannot widen beyond what you staged. +- **Reviewed artifact before approval.** `POST /consent/review` freezes the final batch decision and returns `approval_review_revision`. Final `POST /consent/approve` sends `request_uri`, that revision, and `confirm_reviewed_decision`; it must not submit source choices again. - **One access mode per batch.** Every entry in one batch request must declare the same `access_mode`. If you need different modes for different sources, run separate ceremonies. -- **Independent grants.** Approval issues one independent, source-bounded, individually revocable grant per approved source — the same grant object the single-source path produces. There is no cross-source grant. +- **Independent grants.** Approval issues one independent, source-bounded, individually revocable grant per approved source - the same grant object the single-source path produces. There is no cross-source grant. - **Package grouping.** The issued grants are grouped under a `package_id` for audit and timeline. `package_id` is grouping/audit metadata only; record access is still authorized solely by the active child grants. Per-grant revocation stays primary; a revoke-package convenience dispatches one revoke per child and reports partial failure honestly. #### Incremental add-source (`parent_package_id`) @@ -219,7 +227,7 @@ and set a top-level `parent_package_id` to the prior package: ``` - The new ceremony creates a new package linked to the prior one and issues independent grants **only for the added sources**. It never re-issues or mutates the prior package's grants. -- `parent_package_id` is lineage/cumulative-view metadata, not a new authorization primitive — it grants nothing on its own. +- `parent_package_id` is lineage/cumulative-view metadata, not a new authorization primitive - it grants nothing on its own. - Linkage must be to one of *your own* still-active packages for the same owner. A missing, cross-client, cross-owner, inactive, or malformed `parent_package_id` is rejected before any grant is issued. - The owner-facing dashboard can render the cumulative per-client view across linked packages (reference surface: `GET /_ref/grant-packages/:id/cumulative`). - `parent_package_id` is the signal for the staged add-source path, even when you are adding exactly one source. Without `parent_package_id`, a single-entry request remains the default one-grant path. @@ -238,7 +246,7 @@ Two grants now exist, the user can revoke the upgrade alone, and the audit trail After `pdpp connect` or `POST /consent/approve`, you can inspect any live token against the AS to confirm it is active and read back the full grant it encodes. -This is the authoritative check — it re-runs the grant-contract validation on +This is the authoritative check - it re-runs the grant-contract validation on each call. ```bash @@ -274,12 +282,12 @@ A healthy active client token returns: Key verification points: -- `active: true` — token is valid and the underlying grant is still active. -- `pdpp_token_kind` — `"client"` for grant-scoped tokens, `"owner"` for self-export tokens. -- `grant_id` — confirms which grant backs this token. -- `grant.streams[].resources` — present and populated only when the grant was +- `active: true` - token is valid and the underlying grant is still active. +- `pdpp_token_kind` - `"client"` for grant-scoped tokens, `"owner"` for self-export tokens. +- `grant_id` - confirms which grant backs this token. +- `grant.streams[].resources` - present and populated only when the grant was scoped to specific record keys (see "Record-scoped access with resources[]" below). -- `grant_storage_binding` is **never present** in the public response — the AS +- `grant_storage_binding` is **never present** in the public response - the AS redacts the internal storage connector id before returning the envelope. If a grant has been consumed (`single_use`) or revoked, the token will still exist @@ -295,7 +303,7 @@ Possible `inactive_reason` values: `grant_revoked`, `grant_expired`, `token_revo ## Record-scoped access with `resources[]` -`resources[]` on a stream entry restricts a grant to specific record keys — an +`resources[]` on a stream entry restricts a grant to specific record keys - an RFC 8707-style audience binding at the record level. The RS enforces this as a SQL `WHERE record_key IN (...)` predicate; records outside the list are invisible to that token even if they exist in the store. @@ -312,7 +320,7 @@ to that token even if they exist in the store. Use `resources[]` when the user explicitly named the items they want to share ("just those three invoices", "only the two pull requests I linked"). Do not use -it for time-bounded or field-projected access — that is what `time_range` and +it for time-bounded or field-projected access - that is what `time_range` and `fields` are for. An empty `resources[]` array is equivalent to omitting the field (all records visible within the other grant constraints). diff --git a/docs/biome-exception-ledger.jsonl b/docs/biome-exception-ledger.jsonl index e75e8788f..92a856d3a 100644 --- a/docs/biome-exception-ledger.jsonl +++ b/docs/biome-exception-ledger.jsonl @@ -1972,19 +1972,6 @@ {"classification":"behavior-preservation-exception","invariant":"Explicit property or positional access documents this compatibility boundary.","kind":"inline-suppression","line":787,"path":"reference-implementation/server/explore-timeline-substrate.ts","rule_or_pattern":"lint/style/useDestructuring","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-2a4eb5e47af73b5bead1"} {"classification":"behavior-preservation-exception","invariant":"Explicit property or positional access documents this compatibility boundary.","kind":"inline-suppression","line":821,"path":"reference-implementation/server/explore-timeline-substrate.ts","rule_or_pattern":"lint/style/useDestructuring","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-1cdeee1b7c48f3a00560"} {"classification":"behavior-preservation-exception","invariant":"Work is intentionally sequential to preserve ordering and state transitions.","kind":"inline-suppression","line":921,"path":"reference-implementation/server/explore-timeline-substrate.ts","rule_or_pattern":"lint/performance/noAwaitInLoops","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-fdf9858617f9266c77b9"} -{"classification":"tool-model-mismatch","invariant":"TypeScript boundary permits nullish input; this guard preserves runtime behavior.","kind":"inline-suppression","line":64,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/suspicious/noUnnecessaryConditions","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-4c14997e93f0a04dd0a1"} -{"classification":"tool-model-mismatch","invariant":"TypeScript boundary permits nullish input; this guard preserves runtime behavior.","kind":"inline-suppression","line":770,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/suspicious/noUnnecessaryConditions","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-7da87192c64a6c05ef23"} -{"classification":"tool-model-mismatch","invariant":"TypeScript boundary permits nullish input; this guard preserves runtime behavior.","kind":"inline-suppression","line":772,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/suspicious/noUnnecessaryConditions","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-6f009c4b44b65fe1a0ad"} -{"classification":"tool-model-mismatch","invariant":"TypeScript boundary permits nullish input; this guard preserves runtime behavior.","kind":"inline-suppression","line":841,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/suspicious/noUnnecessaryConditions","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-6edbb2923e4630c608e2"} -{"classification":"tool-model-mismatch","invariant":"TypeScript boundary permits nullish input; this guard preserves runtime behavior.","kind":"inline-suppression","line":843,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/suspicious/noUnnecessaryConditions","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-b43e3ee6f0d1698ed126"} -{"classification":"tool-model-mismatch","invariant":"TypeScript boundary permits nullish input; this guard preserves runtime behavior.","kind":"inline-suppression","line":849,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/suspicious/noUnnecessaryConditions","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-53df570d24b800184513"} -{"classification":"behavior-preservation-exception","invariant":"Work is intentionally sequential to preserve ordering and state transitions.","kind":"inline-suppression","line":1126,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/performance/noAwaitInLoops","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-dd3e4be9071c703c4cea"} -{"classification":"behavior-preservation-exception","invariant":"Work is intentionally sequential to preserve ordering and state transitions.","kind":"inline-suppression","line":1228,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/performance/noAwaitInLoops","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-d67036c3c6092ab415e2"} -{"classification":"behavior-preservation-exception","invariant":"Work is intentionally sequential to preserve ordering and state transitions.","kind":"inline-suppression","line":1407,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/performance/noAwaitInLoops","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-3c78ff05ed1409140a77"} -{"classification":"behavior-preservation-exception","invariant":"Work is intentionally sequential to preserve ordering and state transitions.","kind":"inline-suppression","line":1431,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/performance/noAwaitInLoops","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-dce4aadd73251091991a"} -{"classification":"behavior-preservation-exception","invariant":"Work is intentionally sequential to preserve ordering and state transitions.","kind":"inline-suppression","line":1452,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/performance/noAwaitInLoops","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-4a1efd0b8ead75c6e3ae"} -{"classification":"behavior-preservation-exception","invariant":"Work is intentionally sequential to preserve ordering and state transitions.","kind":"inline-suppression","line":1526,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/performance/noAwaitInLoops","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-f629149ae12ac37c977d"} -{"classification":"behavior-preservation-exception","invariant":"Work is intentionally sequential to preserve ordering and state transitions.","kind":"inline-suppression","line":1641,"path":"reference-implementation/server/grant-package-lifecycle.ts","rule_or_pattern":"lint/performance/noAwaitInLoops","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-1865e768661ffa747917"} {"classification":"tool-model-mismatch","invariant":"TypeScript boundary permits nullish input; this guard preserves runtime behavior.","kind":"inline-suppression","line":196,"path":"reference-implementation/server/hosted-mcp-selection.ts","rule_or_pattern":"lint/suspicious/noUnnecessaryConditions","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-7b8a0d89b23fa07928b7"} {"classification":"tool-model-mismatch","invariant":"TypeScript boundary permits nullish input; this guard preserves runtime behavior.","kind":"inline-suppression","line":292,"path":"reference-implementation/server/hosted-mcp-selection.ts","rule_or_pattern":"lint/suspicious/noUnnecessaryConditions","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-03aebbebc9c480202733"} {"classification":"tool-model-mismatch","invariant":"TypeScript boundary permits nullish input; this guard preserves runtime behavior.","kind":"inline-suppression","line":295,"path":"reference-implementation/server/hosted-mcp-selection.ts","rule_or_pattern":"lint/suspicious/noUnnecessaryConditions","owner":"PDPP reference implementation maintainers","probe":"pnpm --dir reference-implementation run typecheck && pnpm test-accounting:check","review":{"expires_on":"2027-01-31","trigger":"Re-adjudicate when this exact path, rule/pattern, or stated invariant changes."},"id":"biome-2e83dab24af578240787"} diff --git a/docs/operator/blob-fetch-runbook.md b/docs/operator/blob-fetch-runbook.md index b2b5bc627..9d0214f00 100644 --- a/docs/operator/blob-fetch-runbook.md +++ b/docs/operator/blob-fetch-runbook.md @@ -48,7 +48,7 @@ stream with the `blob_ref` field, the RS decorates `blob_ref` with a ``` `fetch_url` is a relative path. Prepend the RS base URL. The `GET /v1/blobs/:blob_id` -route enforces grant scope — the token used to read the record is **the same +route enforces grant scope - the token used to read the record is **the same token** used to fetch the blob. No extra credential is needed. --- @@ -64,7 +64,7 @@ CONNECTOR_ID="https://registry.pdpp.dev/connectors/gmail" SUBJECT_ID="owner_local" ``` -### Step 1 — Upload the blob (connector/owner write path) +### Step 1 - Upload the blob (connector/owner write path) The connector runtime normally writes blobs during a collection run. For manual replay, use the blob upload endpoint directly. Owner token required. @@ -77,7 +77,7 @@ DEVICE=$(curl -s -X POST "$AS_URL/oauth/device_authorization" \ USER_CODE=$(echo "$DEVICE" | jq -r .user_code) DEVICE_CODE=$(echo "$DEVICE" | jq -r .device_code) -# Approve as owner (lab/local only — on a real deployment this happens in the UI) +# Approve as owner (lab/local only - on a real deployment this happens in the UI) curl -s -X POST "$AS_URL/device/approve" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "user_code=$USER_CODE&subject_id=$SUBJECT_ID" @@ -118,7 +118,7 @@ Upload response shape (`HTTP 200`): } ``` -### Step 2 — Seed the parent message and the attachment record +### Step 2 - Seed the parent message and the attachment record Blobs are only reachable via a record that declares `blob_ref`. Seed both: @@ -138,7 +138,7 @@ curl -s -X POST \ -d "{\"key\":\"msg-1:2\",\"data\":{\"id\":\"msg-1:2\",\"message_id\":\"msg-1\",\"filename\":\"invoice.pdf\",\"content_type\":\"application/pdf\",\"size_bytes\":$BLOB_SIZE,\"content_id\":null,\"is_inline\":false,\"encoding\":\"base64\",\"part_index\":\"2\",\"message_received_at\":\"2025-11-01T10:00:00Z\",\"blob_ref\":{\"blob_id\":\"$BLOB_ID\",\"mime_type\":\"$BLOB_MIME\",\"size_bytes\":$BLOB_SIZE,\"sha256\":\"$BLOB_SHA256\"},\"content_sha256\":\"$BLOB_SHA256\",\"hydration_status\":\"hydrated\",\"hydration_error\":null},\"emitted_at\":\"2025-11-01T10:00:00Z\"}" ``` -### Step 3 — Issue a grant-scoped client token that includes `blob_ref` +### Step 3 - Issue a grant-scoped client token that includes `blob_ref` ```bash PAR=$(curl -s -X POST "$AS_URL/oauth/par" \ @@ -165,17 +165,23 @@ PAR=$(curl -s -X POST "$AS_URL/oauth/par" \ }") REQUEST_URI=$(echo "$PAR" | jq -r .request_uri) +REVIEW=$(curl -s -X POST "$AS_URL/consent/review" \ + -H "Content-Type: application/json" \ + -d "{\"request_uri\": \"$REQUEST_URI\", \"subject_id\": \"$SUBJECT_ID\"}") +REVIEW_REVISION=$(echo "$REVIEW" | jq -r .approval_review_revision) + CLIENT_TOKEN=$(curl -s -X POST "$AS_URL/consent/approve" \ + -H "Accept: application/json" \ -H "Content-Type: application/json" \ - -d "{\"request_uri\": \"$REQUEST_URI\", \"subject_id\": \"$SUBJECT_ID\"}" \ + -d "{\"request_uri\": \"$REQUEST_URI\", \"approval_review_revision\": \"$REVIEW_REVISION\"}" \ | jq -r .token) ``` **Key point:** `blob_ref` must be listed in `streams[attachments].fields`. If it is absent, the RS redacts the `blob_ref` field before returning records -and `fetch_url` is never decorated — the blob is not reachable via that token. +and `fetch_url` is never decorated - the blob is not reachable via that token. -### Step 4 — Query records and read the `fetch_url` +### Step 4 - Query records and read the `fetch_url` ```bash RECORDS=$(curl -s \ @@ -221,7 +227,7 @@ Extract the fetch URL: FETCH_URL=$(echo "$RECORDS" | jq -r '.data[0].data.blob_ref.fetch_url') ``` -### Step 5 — Fetch the blob bytes +### Step 5 - Fetch the blob bytes ```bash curl -s -o invoice_downloaded.pdf -D - \ @@ -245,7 +251,7 @@ sha256sum invoice_downloaded.pdf # must match $BLOB_SHA256 ``` -### Step 6 — Grant enforcement: blob is invisible without a matching token +### Step 6 - Grant enforcement: blob is invisible without a matching token Fetching the same blob with a **different token** that does not grant access to the `attachments` stream (or whose grant does not include `blob_ref` in its @@ -271,7 +277,7 @@ The enforcement logic: 3. If the grant projection does not include `blob_ref`, the field is stripped from the record response and the record is considered invisible. 4. If no binding produces a visible record, the route returns `blob_not_found`. - The caller learns only that the blob does not exist — not which connector + The caller learns only that the blob does not exist - not which connector owns it. --- @@ -287,7 +293,7 @@ The enforcement logic: | `Cache-Control` | `private, no-store` (always) | | `Content-Length` | Exact `size_bytes` stored at upload time | | Grant enforcement | Token's grant must grant visibility to the record that carries the `blob_ref`; otherwise `404 blob_not_found` | -| `fetch_url` shape | Relative path `/v1/blobs/<blob_id>` — prepend RS base URL | +| `fetch_url` shape | Relative path `/v1/blobs/<blob_id>` - prepend RS base URL | --- @@ -297,7 +303,7 @@ The enforcement logic: authoritative conformance test for this surface: - `gmail messages expand hydrated attachments with grant-visible blob_ref fetch_url` - — proves Steps 3-5 above using an in-process harness + - proves Steps 3-5 above using an in-process harness - The test at L2913 proves the `blob_not_found` enforcement from Step 6 The tests in `reference-implementation/test/b4-blob-fetch-conformance.test.js` diff --git a/docs/reference/concept-inventory.md b/docs/reference/concept-inventory.md index b6c6329f1..fd498a8f1 100644 --- a/docs/reference/concept-inventory.md +++ b/docs/reference/concept-inventory.md @@ -175,13 +175,13 @@ Legend: ## Source Binding (3 concepts) -The request- and grant-level `source: { kind, id }` object names where authorized data comes from. It replaced the top-level `connector_id` scalar (and the reference contract's sibling `provider_id`) unified in the `2026-04-30-unify-source-binding-vocabulary` change; a request carrying a top-level `connector_id` or `provider_id` is now rejected 400 `invalid_request` (spec-deferred §"Source-binding unification"). The former scalars survive only as the kind-keyed meanings of `source.id`. Distinct from the runtime *bindings* of concepts 62/64 (which are capability requirements like `browser_automation`). +The selection-request `source: { id, kind? }` object and the resolved-grant `source: { kind, id }` object name where authorized data comes from. The request's `source.id` is authoritative; an optional `source.kind` must match the retained declaration, and the AS derives an omitted kind. This replaced the top-level `connector_id` scalar (and the reference contract's sibling `provider_id`) in the `2026-04-30-unify-source-binding-vocabulary` change; a request carrying a top-level `connector_id` or `provider_id` is rejected with 400 `invalid_request` (spec-deferred §"Source-binding unification"). The former scalars survive only as kind-keyed meanings of `source.id`. This is distinct from the runtime *bindings* of concepts 62/64, which are capability requirements such as `browser_automation`. | # | Concept | Description | Flow | Audience | |---|---------|-------------|------|----------| -| 86 | Source binding | Every selection request and issued grant names its data source with one `source: { kind, id }` object; both members required, no others permitted; the grant resolves it from the request at issuance (spec-core §6 request-level params, §7 grant fields) | Spine | Eng, Std | -| 87 | Source kinds (connector vs provider_native) | `source.kind` is `"connector"` (a polyfill connector bridges a platform that does not speak PDPP; `id` is the connector registry key) or `"provider_native"` (the platform serves records directly under its own AS/RS roles; `id` identifies that provider source) (spec-core §6 Source kinds) | Branch | Eng, Std | -| 88 | Unrecognized source kind rejection | An AS that receives a `source.kind` it does not recognize MUST reject with 400 `invalid_request`; consent cannot be rendered for an unrecognized source kind (spec-core §6) | Branch | Eng | +| 86 | Source binding | Every selection request names its data source with `source: { id, kind? }`; the AS derives an omitted kind from the retained declaration. Every issued grant contains `source: { kind, id }` (spec-core §6 request-level params, §7 grant fields) | Spine | Eng, Std | +| 87 | Source kinds (connector vs provider_native) | When supplied in a request, and always in a grant, `source.kind` is `"connector"` (a polyfill connector bridges a platform that does not speak PDPP; `id` is the connector registry key) or `"provider_native"` (the platform serves records directly under its own AS/RS roles; `id` identifies that provider source) (spec-core §6 Source kinds) | Branch | Eng, Std | +| 88 | Unrecognized source kind rejection | An AS that receives an unrecognized `source.kind` MUST reject with 400 `invalid_request`; consent cannot be rendered for that supplied kind (spec-core §6) | Branch | Eng | --- diff --git a/openspec/changes/console-grants-safe-review/design.md b/openspec/changes/console-grants-safe-review/design.md new file mode 100644 index 000000000..d4c461faa --- /dev/null +++ b/openspec/changes/console-grants-safe-review/design.md @@ -0,0 +1,28 @@ +# Design: console grants safe review + +## Scope + +This change adds a console route for one live pending approval and binds consent approval to the immutable PR114 review artifact. It does not change PDPP Core, create a trust registry, fetch client logos, resolve connection labels, or add narrowing controls. + +## Decision + +`GET /_ref/approvals/:approval_id` remains an owner-session-only liveness/type check and owner-device detail projection. For consent approvals, the console does not trust that mutable reconstruction as approval authority. It calls `/consent/review` with the opaque `approval_id`, renders the exact returned `approval_review` artifact, and preserves the returned `request_uri` and `approval_review_revision`. + +The console list is triage only. Both consent and owner-device approvals route to a stable detail page. Consent shows artifact version, subject, AI decision, client, purpose, source declaration, selection preset, resolved instance IDs, resources, fields, time constraints, access, retention, and expiry directly from the artifact. Owner-device review states that it authorizes owner control and has no data-grant scope preview. + +The final confirmation is a second route state (`?confirm=1`), not a modal. Single-consent approval submits only the reviewed `request_uri` and `approval_review_revision`; it does not call `/consent/review` again and it does not submit mutable subject, AI, source, or narrowing facts. If approval fails, the console clears confirmation and returns to read-only review so the next attempt materializes a new immutable artifact. Batch consent is explicitly non-actionable in the console until the required `confirm_reviewed_decision` batch ceremony is implemented there. + +## Alternatives + +- Put full review data in the queue response: rejected because the queue becomes a large sensitive projection and has no clear per-request cache boundary. +- Keep row approval with an interstitial modal: rejected because it adds focus and client-state complexity while producing no stable review URL. +- Claim registered metadata proves a client is verified: rejected because the reference has no server-owned trust decision. + +## Acceptance checks + +- The detail endpoint is gated by the owner session and returns no result for terminal or expired approvals. +- The detail projection does not emit bearer-equivalent credentials or raw persisted payload. +- The console renders the exact `/consent/review` artifact and all authority-bearing artifact fields. +- No pending-list form can approve; only final confirmation has an approval submit for single consent and owner-device approvals. +- Single consent final approval submits only `request_uri` and `approval_review_revision`. +- Approval errors clear `?confirm=1`. diff --git a/openspec/changes/console-grants-safe-review/proposal.md b/openspec/changes/console-grants-safe-review/proposal.md new file mode 100644 index 000000000..a27de26d1 --- /dev/null +++ b/openspec/changes/console-grants-safe-review/proposal.md @@ -0,0 +1,22 @@ +# Console grants safe review + +## Why + +The Grants queue currently issues a consent grant from a list-row action without showing the complete stored request or requiring a final confirmation. + +## What Changes + +- Add an owner-session-only reference approval-detail projection keyed by opaque `approval_id`. +- Replace queue-row approval with a review route and a final confirmation step. +- Preserve direct denial and existing approval semantics, including affirmative AI-training consent. + +## Capabilities + +### Modified + +- `reference-implementation-architecture`: the reference approval queue gains a safe detail read and the console approval path gains a review-before-issue boundary. + +## Impact + +- `GET /_ref/approvals/:approval_id` is a reference/operator endpoint, not a PDPP protocol surface. +- The operator console, reference route adapter, safe projection, and approval tests change. diff --git a/openspec/changes/console-grants-safe-review/specs/reference-implementation-architecture/spec.md b/openspec/changes/console-grants-safe-review/specs/reference-implementation-architecture/spec.md new file mode 100644 index 000000000..0648514d9 --- /dev/null +++ b/openspec/changes/console-grants-safe-review/specs/reference-implementation-architecture/spec.md @@ -0,0 +1,35 @@ +# reference-implementation-architecture Specification Delta + +## ADDED Requirements + +### Requirement: Console consent approval review SHALL bind to the immutable server artifact + +The operator console SHALL render consent approval facts from the server-owned `/consent/review` response for the pending approval. The final single-consent approval form SHALL carry the exact returned `request_uri` and `approval_review_revision`, and its approval mutation SHALL submit only those two fields. It SHALL NOT call `/consent/review` during final approval. + +#### Scenario: Owner reviews a pending single consent + +- **WHEN** an owner opens the console review page for a pending single consent approval +- **THEN** the console SHALL materialize `/consent/review` for that approval and render the returned `reference.approval-review.v1` artifact directly +- **AND** it SHALL render artifact version, subject, AI-training decision, client, purpose, access mode, retention, expiry, source, source declaration version/digest, selection preset, and every `resolved_streams[]` name, `instance_ids`, fields, resources, and time constraint +- **AND** final approval SHALL submit the exact reviewed `request_uri` and `approval_review_revision`. + +#### Scenario: Review revision is stale or unavailable + +- **WHEN** final approval fails because the review artifact is missing, stale, malformed, terminal, or conflicted +- **THEN** the console SHALL return to the read-only review state, not the confirmation state +- **AND** the next owner action SHALL require a newly materialized `/consent/review` artifact before approval can be attempted again. + +### Requirement: Console approval SHALL require review and final confirmation + +The operator console SHALL not render an approval submit in the pending-approvals queue. It SHALL provide a stable review route and render the approval submit only in a final confirmation state for single consent and owner-device approvals. Batch consent SHALL either use the hosted source-review ceremony or render the exact `reference.batch-approval-review.v1` artifact with the batch confirmation protocol; until implemented in console, batch consent SHALL be explicitly non-actionable. + +#### Scenario: Batch consent reaches console review + +- **WHEN** the materialized review artifact has version `reference.batch-approval-review.v1` +- **THEN** the console SHALL render the exact artifact facts +- **AND** it SHALL NOT render a one-click approval submit unless it also submits `confirm_reviewed_decision` with the exact reviewed `request_uri` and `approval_review_revision`. + +#### Scenario: Owner-device review + +- **WHEN** the review projection is for an owner-device authorization +- **THEN** the console SHALL identify it as owner control and SHALL not present data-grant scope or purpose. diff --git a/openspec/changes/console-grants-safe-review/tasks.md b/openspec/changes/console-grants-safe-review/tasks.md new file mode 100644 index 000000000..93623d447 --- /dev/null +++ b/openspec/changes/console-grants-safe-review/tasks.md @@ -0,0 +1,17 @@ +# Tasks + +## 1. Reference detail contract + +- [x] Add an owner-session-only `GET /_ref/approvals/:approval_id` route with a defensive safe projection for pending consent and owner-device approvals. +- [x] Add operation and route regressions for redaction and terminal/expired fail-closed behavior. + +## 2. Console review flow + +- [x] Replace queue-row approval with a review link and a request-specific denial control. +- [x] Add a review route and final confirmation state that renders the exact `/consent/review` artifact, immutable revision, request URI binding, and the owner-device distinction. +- [x] Submit single-consent final approval with only the exact reviewed `request_uri` and `approval_review_revision`; keep batch consent non-actionable in console. + +## 3. Verification + +- [x] Add rendered UI and source-contract regressions for exact artifact fields, revision/request binding, no approval-time review call, batch non-actionability, error confirmation clearing, and owner-device wording. +- [x] Run focused server and console tests, type checks, Biome, and strict OpenSpec validation. diff --git a/openspec/changes/define-source-declaration-discovery-and-trust/design.md b/openspec/changes/define-source-declaration-discovery-and-trust/design.md new file mode 100644 index 000000000..f6fa8c5b7 --- /dev/null +++ b/openspec/changes/define-source-declaration-discovery-and-trust/design.md @@ -0,0 +1,147 @@ +# Design: Source Declaration discovery and trust + +## Ownership + +| Contract | Owns | This change's dependency | +|---|---|---| +| Source Declaration contract | `SourceDeclaration`, its schema, accepted source/revision snapshot, and declaration-defined authorization terms | Discovery consumes the schema and snapshot. It does not redefine them. | +| This discovery and trust change | Provider-native discovery pointer, source onboarding inputs, authority binding, bounded retrieval, exact validation, and local blocking for new consent | It must not redefine grants, consent snapshots, Core schema, or Collection. | + +The Source Declaration contract must be available before an implementation can +validate or retain declarations. + +## Boundary + +Discovery selects and accepts a declaration authority. The Source Declaration +change owns the declaration schema and snapshot. Core owns grants and consent +semantics. Collection remains optional and owns collection execution. Discovery +does not copy any of those contracts or define how the Resource Server enforces +an issued grant. + +## Provider-native discovery + +For the requested protected-resource identifier, the AS uses RFC 9728 +protected-resource metadata and RFC 9728's standard well-known URI +transformation. The returned metadata `resource` member must equal the +requested protected-resource identifier under RFC 9728's exact comparison +rule. PDPP adds the provider-native metadata member +`pdpp_source_declaration_uri`. It is one HTTPS URI string with no fragment or +user information. +The member is optional in generic protected-resource metadata because a +multi-source personal server or hosted MCP resource need not map to one Source +Declaration. Provider-native onboarding requires it for the specific protected +resource being accepted. + +The retrieved `SourceDeclaration.source.kind` must be `provider_native`, and +`SourceDeclaration.source.id` must equal the accepted protected-resource +identifier under the Source Declaration contract. Either mismatch fails +closed. + +TLS-authenticated protected-resource metadata is authoritative for its +declaration pointer. The declaration URI may be hosted on another origin. A +cross-origin host does not, by itself, authenticate `publisher.id`. Publisher +attribution is authenticated only by an accepted channel or configured mapping. +The AS stores resource authority and publisher attribution as separate facts. +Without that authentication, the publisher value remains a non-authoritative +claim and cannot support attribution or any trust decision. + +## Onboarding and trust + +An ordinary authorization request may name only a source already accepted by +the AS. A new provider-native resource identifier enters through explicit owner +or operator source onboarding. A client cannot select a new resource identifier +or declaration URI during authorization. + +Connector and community sources come from an installed catalog, a trusted +registry entry, or explicit local provisioning. The client may name an accepted +source, but cannot turn an arbitrary URL into an authority. + +## Retrieval and revision integrity + +Declaration retrieval uses HTTPS, no ambient credentials, bounded bytes, time, +and retrieval depth, exact source and revision validation, and fail-closed +outcomes. Redirect handling is local policy: every redirect target and the +final declaration URL must satisfy the accepted pointer and configured +redirect policy. The policy may reject all redirects. For each connection +attempt, including every redirect hop, the retriever resolves the destination +again, validates every resolved address against the applicable network policy, +and connects only to an address from that validated result while preserving the +destination authority for TLS authentication. This prevents an earlier DNS +decision from authorizing a later rebound address. Source identity validation +remains a separate check against `SourceDeclaration.source.id`; a declaration +URL is not reinterpreted as the source identifier. These rules are PDPP +retrieval policy, not RFC 9728 redirect rules. Implementations do not +automatically fetch remote schemas. Private or local endpoints are allowed only +through explicit local provisioning or onboarding. Such provisioning supplies +the applicable network policy; a broad IP-address exclusion list is not +universal protocol conformance. + +An accepted revision is identified by the accepted authority binding, +`source.id`, and opaque `declaration_version`. After parsing and validating the +JSON, later content under the same key must compare equal as parsed JSON. A +deployment may use an internal content fingerprint to make that comparison +efficient, but its algorithm is not a protocol identity or cross-implementation +digest. When provider-native discovery is used for consent, the AS must retain +an unambiguous AS-local accepted-revision reference in consent and audit +evidence. The reference addresses this AS's accepted authority binding and +parsed revision only. It is not a portable authorization right, grant identity, +bearer handle, or cross-AS declaration credential. A current pointer that +returns a prior revision is not ordered or +rejected by `declaration_version`; accepting it requires explicit publisher or +local policy. A different parsed document under the same revision is +equivocation and is rejected. + +Consent resolves that reference from the accepted-revision store once and +uses the exact retained declaration without a network refetch. The immutable +consent snapshot, approval review, and audit events retain the reference, +verified resource-authority binding, and separate unverified publisher +attribution. Direct local operator provisioning carries no accepted-revision +reference and is labeled `local_operator_provisioned`, not verified discovery. +The fulfillment manifest may still supply local sensitivity policy, but no +Source Declaration fact. + +Display values are untrusted and must be escaped for their output context. +The implementation defines configured whole-response, parser, and display +maxima. Values over a configured maximum are rejected before consent rendering +or logging. This change does not add fixed display `maxLength` numbers to the +Source schema. + +Current declaration query capability is separate from issued-grant rights. This +change does not add expansion constraints to grants. Unless the Source +Declaration change explicitly makes expansion an authorization constraint, +current expansion capability must not widen the streams or fields in an issued +grant. + +## Lifecycle + +If a declaration is locally blocked, it cannot be used for new consent. That +block does not automatically revoke historical grants. Quarantine records and a +quarantine workflow are deferred. Existing grant behavior remains owned by the +Core grant and consent contracts. + +## Alternatives rejected + +- Client-selected declaration URLs or new resources: they would let a request + choose an authority and bypass onboarding. +- Treating a cross-origin declaration host as publisher authentication: resource + authority and publisher attribution are distinct trust facts. +- Parsed-JSON or digest-based portability rules: they add a comparison contract + that this change does not need. +- Universal private-address rejection: local deployments need an explicit, + narrow provisioning path, while protocol conformance stays transport-focused. +- Collection as a prerequisite: provider-native and pre-collected Core sources + must work without Collection Profile semantics. + +## Claim classification + +| Decision | Class | Basis | +|---|---|---| +| RFC 9728 metadata lookup, standard well-known transformation, and exact returned-resource comparison | primary precedent | RFC 9728 | +| `pdpp_source_declaration_uri`, HTTPS/no fragment, provider-native kind, source ID equality, onboarding, and authority separation | PDPP policy | Cross-redteam and discovery implementation review | +| Parsed-content immutability keyed by authority, source ID, and opaque version, with a required AS-local accepted-revision evidence reference for provider-native consent | PDPP policy | Collection rereview and implementation map | +| Per-connection DNS/IP validation, hop-by-hop redirect policy, and final declaration URL validation | PDPP policy | Retrieval threat model; not an RFC 9728 rule | +| Current-pointer rollback requires explicit publisher/local policy | PDPP policy | Opaque revision semantics and lifecycle boundary | +| Client-supplied arbitrary declaration URLs are rejected | PDPP policy | Authority substitution and SSRF threat; no live accepting path is claimed | +| Normal declaration evolution does not reinterpret resolved grants | demonstrated defect | Prior review identified live declaration dependence as a grant-widening risk | +| No schema, grant, consent snapshot, Core, or Collection duplication | PDPP policy | Contract ownership matrix | +| Local blocking without automatic historical grant revocation; quarantine deferred | PDPP policy | Cross-redteam review | diff --git a/openspec/changes/define-source-declaration-discovery-and-trust/proposal.md b/openspec/changes/define-source-declaration-discovery-and-trust/proposal.md new file mode 100644 index 000000000..18b8c4a0a --- /dev/null +++ b/openspec/changes/define-source-declaration-discovery-and-trust/proposal.md @@ -0,0 +1,52 @@ +# Proposal: Define Source Declaration discovery and trust + +## Why + +The Source Declaration contract needs a bounded discovery and trust boundary. +The authorization server must not let an ordinary authorization request choose +an unaccepted authority, and a later declaration must not silently replace the +accepted source or revision. + +## What changes + +- Add provider-native discovery through RFC 9728 protected-resource metadata. +- Publish the discovery and trust rules as an authoritative root companion + specification. +- Define `pdpp_source_declaration_uri` as one HTTPS URI string with no fragment + or user information. +- Require the RFC 9728 returned-resource equality rule, plus the explicit PDPP + source ID equality rule. +- Define owner/operator onboarding for provider-native resources and trusted + catalog, registry, or local inputs for connector and community sources. +- Separate resource authority from publisher attribution. +- Define bounded HTTPS retrieval and immutable accepted revision content. +- Keep display escaping and response/parser limits as implementation policy. +- Block declarations locally for new consent without automatic historical grant + revocation. Defer quarantine records and workflow. + +This change consumes the Source schema and accepted snapshot from the Source +Declaration contract. It does not redefine grants, consent snapshots, Core +schema, or Collection. Reference-server metadata emission, declaration +retrieval, onboarding adapters, persistence, and consent integration are +deferred to implementation changes. + +## Capabilities + +### Added + +- `source-declaration-discovery-and-trust` + +### Modified + +- None + +### Removed + +- None + +## Impact + +The protocol gains an authoritative discovery boundary, immutable +accepted revisions, and a protected-resource metadata extension for +provider-native sources. The public contract package and focused protocol +contract tests are updated. Collection remains optional. diff --git a/openspec/changes/define-source-declaration-discovery-and-trust/specs/source-declaration-discovery-and-trust/spec.md b/openspec/changes/define-source-declaration-discovery-and-trust/specs/source-declaration-discovery-and-trust/spec.md new file mode 100644 index 000000000..7e6629781 --- /dev/null +++ b/openspec/changes/define-source-declaration-discovery-and-trust/specs/source-declaration-discovery-and-trust/spec.md @@ -0,0 +1,234 @@ +# Source Declaration discovery and trust + +## ADDED Requirements + +### Requirement: Provider-native discovery SHALL bind the accepted resource and declaration + +For an already onboarded provider-native protected-resource identifier, that +identifier SHALL be an HTTPS URL without a fragment or user information and +SHOULD NOT contain a query component. The AS SHALL form the RFC 9728 metadata URL by inserting +`/.well-known/oauth-protected-resource` between the host component and any path +or query components. When a path or query is present, it SHALL remove the +terminating slash following the host before insertion. The AS SHALL retrieve +that URL with HTTP `GET`. The returned metadata `resource` member SHALL be +identical to the protected-resource identifier used to form that URL. The +metadata extension `pdpp_source_declaration_uri` SHALL be one HTTPS URI string +with no fragment or user information when the protected resource is being +onboarded as a provider-native source. The extension MAY be absent from generic +protected-resource metadata for resources that do not map to one Source +Declaration. PDPP SHALL require `SourceDeclaration.source.kind` to be +`provider_native` and `SourceDeclaration.source.id` to equal the accepted +protected-resource identifier under the Source Declaration contract. The AS +SHALL consume the Source schema and accepted snapshot defined by that contract +and SHALL fail closed on any mismatch. + +#### Scenario: Metadata points to the matching declaration + +- **WHEN** accepted metadata contains a valid extension and the declaration's source ID equals the protected resource under the required comparison +- **THEN** the AS SHALL accept the declaration for validation and consent + +#### Scenario: Provider-native metadata extension is invalid + +- **WHEN** provider-native onboarding metadata has an extension that is missing, + not one HTTPS URI string, contains a fragment, or contains user information +- **THEN** discovery SHALL fail closed before declaration use + +#### Scenario: Generic metadata has no declaration pointer + +- **WHEN** a generic personal-server or hosted MCP protected resource does not map to one Source Declaration +- **THEN** its RFC 9728 metadata MAY omit `pdpp_source_declaration_uri` + +#### Scenario: Declaration names another resource + +- **WHEN** `SourceDeclaration.source.id` does not equal the provider-native protected resource +- **THEN** the AS SHALL reject the declaration and SHALL NOT use it for consent + +#### Scenario: Declaration has the wrong source kind + +- **WHEN** provider-native discovery retrieves a declaration whose + `SourceDeclaration.source.kind` is not `provider_native` +- **THEN** the AS SHALL reject the declaration and SHALL NOT use it for consent + +### Requirement: Source onboarding SHALL precede ordinary authorization + +An ordinary authorization request SHALL name only a source already accepted by +the AS. A new provider-native resource identifier SHALL enter only through +explicit owner or operator onboarding. Connector and community sources SHALL +come from an installed catalog, a trusted registry entry, or explicit local +provisioning. A client SHALL NOT select a new authority or arbitrary +declaration URL during authorization. + +This is explicit PDPP policy responding to authority substitution and SSRF +threats. It does not claim that RFC 9728 requires this connector/community +onboarding model. + +#### Scenario: Client names an unaccepted source + +- **WHEN** an authorization request names a source not accepted by the AS +- **THEN** the AS SHALL reject the request before discovery retrieval + +#### Scenario: Explicit local provisioning accepts a private endpoint + +- **WHEN** an operator explicitly provisions a private or local source endpoint +- **THEN** the AS MAY use that endpoint under the local provisioning policy +- **AND** the endpoint SHALL NOT become a general protocol conformance rule + +### Requirement: Resource authority and publisher attribution SHALL be separate + +TLS-authenticated protected-resource metadata SHALL be authoritative for its +declaration pointer. Cross-origin declaration hosting MAY be used, but the host +alone SHALL NOT authenticate `publisher.id`. Publisher attribution SHALL be +authenticated only by an accepted channel or configured mapping. The AS SHALL +store resource authority and publisher attribution separately. Without that +authentication, the declared publisher SHALL remain a non-authoritative claim +and SHALL NOT be used for attribution, source acceptance, redirect approval, +or any other trust decision. + +#### Scenario: Cross-origin declaration host lacks a publisher binding + +- **WHEN** metadata points to a declaration on another origin and no accepted channel or configured mapping binds its publisher +- **THEN** the AS SHALL not treat the origin as authentication of `publisher.id` +- **AND** the declared publisher SHALL remain non-authoritative and unusable for trust decisions + +### Requirement: Declaration retrieval SHALL be bounded and fail closed + +The AS declaration retriever SHALL use HTTPS, no ambient credentials, bounded +response bytes, time, and retrieval depth, exact source and revision +validation, and fail-closed outcomes. Redirect handling SHALL follow a local +policy: every redirect target and the final declaration URL SHALL satisfy +the accepted pointer and configured redirect policy. The policy MAY reject all +redirects. For each connection attempt, including every redirect hop, the +retriever SHALL perform a fresh DNS resolution, SHALL validate every resolved +address against the applicable network policy before connecting, and SHALL +connect only to an address from that validated result while preserving the +destination authority for TLS authentication. An earlier DNS result SHALL NOT +authorize a later address. The retrieved `SourceDeclaration.source.id` SHALL be +validated separately against the accepted protected-resource identifier. These +are PDPP retrieval rules, not RFC 9728 redirect rules. The AS SHALL NOT +automatically fetch remote schemas. Private or local endpoints SHALL require +explicit local provisioning or onboarding that supplies the applicable network +policy. A universal private-address exclusion list is not a protocol +conformance requirement. + +#### Scenario: Retrieval exceeds a bound or fails redirect policy + +- **WHEN** retrieval exceeds the configured bytes, time, or depth bound, or a + redirect target, final declaration URL, DNS result, or resolved address fails + the applicable policy +- **THEN** retrieval SHALL fail closed and SHALL NOT produce an accepted declaration + +#### Scenario: DNS changes between connection attempts + +- **WHEN** a destination resolves again for a redirect hop or later connection attempt +- **THEN** every newly resolved address SHALL pass the applicable network policy before connection +- **AND** an address accepted for an earlier connection SHALL NOT authorize the new result + +#### Scenario: Declaration requests a remote schema + +- **WHEN** validation would require automatic remote schema retrieval +- **THEN** the AS SHALL reject the declaration rather than fetch the schema + +### Requirement: Accepted revisions SHALL be immutable by validated parsed content + +An accepted revision SHALL be keyed by its accepted authority binding, +`source.id`, and opaque `declaration_version`. After JSON parsing and +validation, later content under the same key SHALL compare equal as parsed +JSON. An implementation MAY use an internal content fingerprint to accelerate +that comparison, but its algorithm SHALL NOT be a protocol identity or +cross-implementation digest. When the AS uses provider-native discovery for +consent, its consent and audit evidence SHALL retain an unambiguous AS-local +accepted-revision reference to the accepted authority binding and parsed +revision retained by this AS. That reference SHALL NOT be a portable +authorization right, grant identity, bearer handle, or cross-AS declaration +credential. A different parsed document under the same +key SHALL be rejected as equivocation. A current pointer to a prior revision +SHALL be accepted or rejected only under explicit publisher or local policy; +the AS SHALL NOT infer ordering or freshness from `declaration_version`. + +#### Scenario: Same revision returns different parsed JSON + +- **WHEN** a later response under the same authority, source ID, and version key parses to a different JSON value +- **THEN** the AS SHALL reject the response as equivocation +- **AND** it SHALL retain the accepted parsed content + +#### Scenario: Accepted revision reference is retained as audit evidence only + +- **WHEN** the AS uses provider-native discovery for consent +- **THEN** consent and audit evidence SHALL retain an unambiguous AS-local + reference to the accepted authority binding, source ID, and declaration + version +- **AND** the reference SHALL NOT authorize access, replace grant rights, or be + treated as portable identity outside that AS + +#### Scenario: Version values are opaque + +- **WHEN** two accepted declarations have different `declaration_version` values +- **THEN** the AS SHALL not infer ordering or freshness from those values + +#### Scenario: Current pointer returns a prior revision + +- **WHEN** a current pointer names a previously accepted revision +- **THEN** the AS SHALL apply the explicit publisher or local rollback policy +- **AND** it SHALL not infer acceptance or rejection from opaque version ordering + +### Requirement: Display and parser safety SHALL remain implementation policy + +Declaration-provided display values SHALL be escaped for their output context. +The implementation SHALL define configured whole-response, parser, and display +maxima. A value over its configured maximum SHALL be rejected before consent +rendering or logging. This change SHALL NOT add fixed display `maxLength` +numbers to the Source schema. + +#### Scenario: Declaration text reaches consent output + +- **WHEN** accepted declaration text is rendered or logged +- **THEN** the implementation SHALL escape it for that output context +- **AND** the implementation SHALL reject it when it exceeds the configured maximum for that display field + +### Requirement: Current query capability SHALL not widen issued grants + +Current SourceDeclaration query capability, including expansion capability, is +separate from issued-grant rights. Unless the Source Declaration contract +explicitly makes expansion an authorization constraint, current expansion SHALL +not widen the streams or fields in an issued grant. This change consumes that +contract and does not redefine it. + +#### Scenario: Current expansion is broader than an issued grant + +- **WHEN** a later declaration advertises expansion that reaches a stream or + field absent from an existing grant +- **THEN** the current capability SHALL not authorize that stream or field + under the issued grant + +### Requirement: Local blocking SHALL not revoke historical grants automatically + +An AS MAY locally block a declaration from new consent. That block SHALL NOT +automatically revoke historical grants. Quarantine records and quarantine +workflow are deferred. Grant and consent snapshot semantics remain owned by +their separate Core and Source Declaration contracts. + +#### Scenario: Blocked declaration has a historical grant + +- **WHEN** a declaration is locally blocked after a historical grant was issued +- **THEN** new consent using that declaration SHALL be blocked +- **AND** the historical grant SHALL not be automatically revoked by this rule + +### Requirement: Collection Profile SHALL remain optional + +Discovery and trust SHALL consume the Source schema and snapshot without +requiring Collection Profile data. A connector MAY use Collection Profile +semantics, but this change SHALL NOT redefine or require Collection. + +#### Scenario: Core-only source is accepted + +- **WHEN** an accepted provider-native or pre-collected source has no Collection Profile data +- **THEN** discovery SHALL still be able to accept its declaration + +## Explicit exclusions + +This change depends on the Source Declaration contract for `SourceDeclaration`, +accepted snapshots, and declaration-defined authorization constraints. It does +not duplicate those requirements. This change does not define grants, consent +snapshots, the Core Source schema, the Collection Profile, quarantine records +or workflow, cross-implementation digests, cache-key grammar, version ordering, +federation, or signed declaration credentials. diff --git a/openspec/changes/define-source-declaration-discovery-and-trust/tasks.md b/openspec/changes/define-source-declaration-discovery-and-trust/tasks.md new file mode 100644 index 000000000..4956d36af --- /dev/null +++ b/openspec/changes/define-source-declaration-discovery-and-trust/tasks.md @@ -0,0 +1,77 @@ +# Tasks + +## Normative contract + +- [x] Publish the discovery and trust requirements in the authoritative root + companion specification and its generated public-site page. +- [x] Consume the Source schema and accepted snapshot from the Source + Declaration contract. Do not redefine grants, consent snapshots, Core schema, + or Collection. +- [x] Define `pdpp_source_declaration_uri` as one HTTPS URI string with no + fragment or user information, use RFC 9728's standard well-known + transformation, and apply the RFC 9728 returned-resource equality rule. +- [x] Require `SourceDeclaration.source.kind` to be `provider_native` and + `SourceDeclaration.source.id` to equal the provider-native protected + resource. +- [x] Require owner/operator onboarding before a new provider-native resource + identifier can be used in authorization. +- [x] Accept connector and community sources only from an installed catalog, + trusted registry entry, or explicit local provisioning. +- [x] Store resource authority separately from publisher attribution. Keep a + publisher claim non-authoritative and unusable for trust decisions unless an + accepted channel or configured mapping authenticates it. +- [x] Require fresh DNS resolution and validation of every resolved address for + each connection attempt and redirect hop. Validate redirect targets and the + final declaration URL against the accepted pointer and configured policy, + while validating `source.id` separately against the protected resource. + +## Public protocol contract + +- [x] Add optional `pdpp_source_declaration_uri` to generic protected-resource + metadata and a focused provider-native validator that requires the pointer + and exact resource identity, without defining a second Source or grant schema. +- [x] Add protocol-contract tests for missing, invalid, fragment-bearing, and + valid declaration pointers, malformed resource identifiers, RFC 9728 + well-known URI transformation including root-slash forms, and exact + returned-resource mismatches. + +## Standalone reference implementation + +- [x] Emit a configured provider-native declaration pointer only in native + metadata, and reject an invalid pointer before response emission. +- [x] Add credential-free, bounded declaration retrieval with injected + fetch/DNS/address/URL/schema-validation policy, per-hop fresh address + validation, manual redirects, and source-ID validation. +- [x] Persist accepted revision content immutably by authority binding, source + ID, and opaque declaration version on SQLite and PostgreSQL; reject parsed + content equivocation without version ordering. +- [x] Add deterministic retrieval, metadata, SQLite, and real PostgreSQL + parity coverage for this standalone boundary. + +Onboarding adapters, local blocking, and lifecycle behavior remain outside +this standalone implementation slice. + +## Accepted revision consent handoff + +- [x] Resolve one internal source-bound accepted-revision reference from the + accepted store and retain its exact declaration in consent without refetch. +- [x] Retain the accepted reference, resource authority, and separate + unverified publisher attribution in immutable review and audit evidence, + but not in resolved grant rights. +- [x] Label direct provider-native configuration as local operator + provisioning and fail closed on missing, mismatched, stale, or tampered + accepted-revision evidence. +- [x] Prove the HTTP PAR, review, HTML resume, approval, and audit path on + SQLite and live PostgreSQL, including pointer drift and offline retrieval. + +## Validation + +- [x] Run the reference-contract tests, typecheck, and style check. Regenerate + checked-in contract artifacts twice and verify stable output. +- [x] Run `pnpm spec:check`. +- [x] Run `openspec validate define-source-declaration-discovery-and-trust --strict`. +- [x] Run `openspec validate --all --strict`. The target change passes; 10 + unrelated existing changes remain invalid. +- [x] Run a target diff check and stale sweeps for removed dependencies, + source-identity redirect checks, unauthenticated publisher trust, incomplete + DNS/IP validation, and em dashes. diff --git a/openspec/changes/define-source-declarations-and-resolved-grants/tasks.md b/openspec/changes/define-source-declarations-and-resolved-grants/tasks.md index 60cbda29a..4d378348d 100644 --- a/openspec/changes/define-source-declarations-and-resolved-grants/tasks.md +++ b/openspec/changes/define-source-declarations-and-resolved-grants/tasks.md @@ -29,14 +29,14 @@ violates the Source request or narrowing contract. Leave the OAuth response mapping to PR89. -- [ ] 3. Implement snapshot and mutation barriers. +- [x] 3. Implement snapshot and mutation barriers. - Pass one exact snapshot through validation, display, narrowing, issuance, and evidence. - At barriers before display, narrowing, and issuance, mutate, delete, and same-version-replace the current catalog entry. Prove every phase still uses the retained snapshot and fails closed if that snapshot is lost. -- [ ] 4. Separate authorization facts from serving metadata. +- [x] 4. Separate authorization facts from serving metadata. - Make RS enforcement use only the resolved authorization context. - Define separate client-token and owner/discovery metadata projections. Client schema, stream, search, and record metadata must be grant-projected; @@ -44,7 +44,7 @@ route or reject unsupported resolved constraints, but must not reinterpret resource keys, widen grants, or change the frozen time field. -- [ ] 5. Make the authorization-state break fail closed. +- [x] 5. Make the authorization-state break fail closed. - Accept only the new retained-snapshot pending shape and closed resolved grant shape after this change. - Reject pre-v0.1 pending consent, grants, and packages and require fresh @@ -53,7 +53,7 @@ - Prove that a legacy per-stream `connection_id` never becomes one or more current `instance_ids` during approval or serving. -- [ ] 6. Add implementation oracles and ownership gates. +- [x] 6. Add implementation oracles and ownership gates. - Add a Core-only dependency oracle that imports no Collection schema or runtime module and proves a connector declaration works without an extension. @@ -66,7 +66,7 @@ grant-enforcement dependency or create a second grant shape. - Limit other Collection work to compatibility with the neutral contract. -- [ ] 7. Verify the change. +- [x] 7. Verify the change. - Run focused contract, snapshot, instance, upgrade-boundary, and RS tests. - Run `openspec validate define-source-declarations-and-resolved-grants --strict`, `git diff --check`, and stale-term sweeps for deleted live @@ -113,3 +113,33 @@ routes. Preserve owner-token current-capability expansion. - [x] Prove SQLite and live-PostgreSQL parity with a same-name relationship repointed to a different granted stream and foreign key after issuance. + +## PR114 corrective checkpoint + +- [x] Single-source approval requires a persisted reviewed revision before + issuance. +- [x] The reviewed artifact freezes retained declaration evidence, source, + exact resolved instance IDs, streams, fields, resources, time, purpose, + retention, client, subject, and grant expiry. +- [x] Single-source approval recomputes the reviewed artifact after current + instance eligibility revalidation and rejects stale review revisions. +- [x] Single-source approval writes the pending-row CAS claim, grant, token, + approval events, and final approved state in one SQLite or PostgreSQL + transaction, with a typed conflict for CAS losers. +- [x] Request-time source fulfillment no longer falls back from source kind/id + to a canonical connector key. Source fulfillment must be explicit. +- [x] Selection request `source.kind` may be omitted; when omitted, the AS + derives provenance from the retained declaration. SourceDeclaration and + resolved grants still require `source.kind`. +- [x] Staged batch approval uses the same reviewed-artifact and atomic + transaction seam. The finalized batch review binds approved source indexes, + exact resolved source/stream facts, parent linkage, member order, and the + posted review revision before issuing the package. +- [x] Client-token record reads reject query-time views and use only explicit + fields or the grant's frozen projection; owner-token reads may resolve + current views. +- [x] Client source descriptors fail closed when the resolved grant has no + valid public source instead of exposing a private storage connector ID. +- [x] Immutable single and batch approval-review artifacts bind rendered + `client_claims`, while issued grants and Resource Server rights remain + unchanged by claims-only differences. diff --git a/openspec/changes/harden-pdpp-authorization-and-0-1-migration/design.md b/openspec/changes/harden-pdpp-authorization-and-0-1-migration/design.md index fe1532256..d79f6cdb5 100644 --- a/openspec/changes/harden-pdpp-authorization-and-0-1-migration/design.md +++ b/openspec/changes/harden-pdpp-authorization-and-0-1-migration/design.md @@ -1,390 +1,192 @@ ## Context -This change ("PR1") is the B-intersect-C-invariant subset of a larger architecture -decision under consideration for PDPP's authorization model (Stage 3 synthesis and -architecture decision, the red-team addendum, and the author's controlling response, -all dated 2026-08-04, in `local/refactor/`). That decision proposes decomposing the -specification into separate Core / OAuth-binding / owner-profile documents and closing -nine common 0.2 schemas. The author's controlling response accepts the direction but -rejects publishing that decomposition and those schemas as settled normative text before -a seam-spike experiment runs: doing so would make the experiment's own fallback option -(reverting to a less mechanism-separated design) fictional, since a failed experiment -would then require undoing already-published normative surfaces. - -This change is scoped to the subset of hardening and migration requirements that hold -**regardless of that experiment's outcome** — the separated-RS authorization-context -hole, the credential-lifecycle gaps (authorization-code reuse, refresh replay, keyless -recovery), the credential-security floor, the 0.1 migration profile, and the two -canonicalization prerequisites the experiment itself depends on. It runs in parallel -with the seam-spike, not after it. - -`spec-core.md` today has no OAuth-flow-mechanics text in its normative sections at all -(Sections 4-10) beyond a relationship-table mention and one informative note. This is -deliberate: Core is meant to stay binding-independent. Several requirements below are -therefore new normative text rather than edits to existing OAuth prose, and are phrased -as binding-neutral security invariants (e.g., "a credential exchange step SHALL be -one-time-use") rather than OAuth-specific phrasing, reserving fully OAuth-specific -mechanism text for a future OAuth-binding document. - -## Goals / Non-Goals - -**Goals:** - -- Close the separated-RS authorization-context hole so an RS can run PDPP's own - enforcement algorithm from what introspection is specified to return. -- Repair three credential-lifecycle gaps (authorization-code reuse, refresh-replay - open-endedness, keyless recovery) with unconditional, testable rules. -- Give a source a normative way to declare and enforce a minimum credential-security - floor, with mandatory disclosure of a permitted weaker mode. -- Deliver a normative v0.1 migration profile so an implementer upgrading an RS has a - defined outcome for every live credential kind. -- Pin the two canonicalization prerequisites (JSON Schema dialect, timestamp/duration - profile) the seam-spike and any future grant-digest computation depend on. -- State, once, that the Core/binding decomposition and closed 0.2 schemas remain gated - on the seam-spike, replacing scattered and partially inconsistent statements of that - gate. - -**Non-Goals:** - -- Deciding or drafting the three-document Core/binding decomposition. -- Closing any of the nine 0.2 common schemas (`PDPPSelection`, `PDPPApprovedSelection`, - `PDPPRequesterIdentity`, `PDPPConsentEvidence`, `PDPPGrant`, `PDPPGrantState`, - `PDPPCredentialFamily`, `PDPPAuthorizationContext`, `PDPPError`). -- Defining the multidimensional requester-identity structure or the canonical grant - digest algorithm as fixed, cross-binding-stable objects — both are exactly what the - seam-spike is meant to validate. -- Any normative GNAP binding or GNAP conformance label. -- Deciding DPoP's mandatory-to-implement status. This change specifies the DPoP duty - split and the security-floor mechanism conditionally ("when a deployment uses DPoP", - "when a source declares a floor") without deciding whether DPoP adoption itself is - required. -- Minting `urn:pdpp:...:0.2` identifiers, a "v0.2" version label, or any part of the - full conformance-claim label taxonomy. Those remain gated on unresolved registry - governance. -- Editing any root `spec-*.md` file. This proposal is the OpenSpec change; root spec - edits follow after acceptance. - -## Key Decisions - -### Change-class labeling: three labels, used consistently - -Every requirement in this change carries exactly one of three labels, stated inline as -**Change class:** immediately before its scenarios: - -- **formalizes an existing v0.1 semantic requirement** — restates an obligation v0.1 - already implied, without adding new enforceable surface. -- **repairs an existing interoperability/security hole** — closes a gap in already- - presupposed or already-partially-specified behavior. -- **introduces a genuinely new normative capability** — adds normative surface with no - v0.1 precedent. - -This labeling exists so the change is honestly described as a bounded hardening and -migration change containing some genuinely new normative capability, not merely a -small extraction of already-existing structure. Every requirement in this change is -labeled either "repairs an existing interoperability/security hole" or "introduces a -genuinely new normative capability" (the credential-security floor's declaration -requirement and the two canonicalization pins); none is labeled "formalizes an existing -v0.1 semantic requirement," since v0.1 has no prior text on any of these questions to -formalize. - -### The authorization context uses the standard RFC 9396 carrier, not a proprietary shadow copy - -RFC 9396 §9.2 registers `authorization_details` as a top-level member of the RFC 7662 -introspection response, and §14.3 profiles its IANA registration. The prior design -draft used essentially this shape but treated more of the introspection transport as -PDPP-proprietary than necessary, risking a second, competing schema for data RFC 9396 -already carries. - -This change requires the normalized, approved selection (the streams/fields/time_range/ -resources projection a grant actually authorizes) to travel in `authorization_details`, -the same carrier already normative for the client's authorization request. A small, -separate `pdpp` supplementary member carries only what `authorization_details` cannot -express: immutable grant identity, grant lifecycle state, a consent-evidence reference, -and the security/binding profile in effect. The `pdpp` member is explicitly prohibited -from duplicating any field `authorization_details` already carries — a reviewer checking -for a shadow copy of streams/fields/time_range/resources should find none. - -**Alternative considered:** a single PDPP-proprietary introspection response shape -carrying all context fields, avoiding a second reference into RFC 9396. Rejected: this -would mean PDPP defines its own transport for data an existing, IANA-registered OAuth -extension already carries, and gives two implementations no shared, standards-derived -contract to converge on for the RAR-carriable fields. - -### Authorization-code reuse: no idempotency carve-out, of any kind - -An earlier draft allowed a token-endpoint response to be idempotent for retransmission -"only if it can return the same credential value... an implementation optimization, not -an interoperability requirement." A subsequent review proposed narrowing that allowance -to same-DPoP-key retransmission only. The controlling author response rejects that -narrower carve-out outright: "Same-key proof does not repeal the authorization code's -one-use rule." - -This change states the rule unconditionally: a redeemed authorization code denies any -second presentation with `invalid_grant` (or the binding's equivalent), independent of -whether the same DPoP-bound key is presented. Lost-response recovery, if a binding -chooses to support it, must be a separately named transaction-result or idempotency -extension bound to the original committed issuance — never a second code redemption. -This composes with the existing `single_use` atomic-consumption pattern (`spec-core.md` -line 596: the AS marks a credential consumed atomically with issuance) by extending the -same atomicity guarantee to the code-redemption step that precedes it. - -**Alternative considered:** the same-DPoP-key idempotency carve-out. Rejected per the -controlling author response; including it would directly contradict the hard constraint -this change operates under. - -### Refresh-replay responses: a closed enumeration, not an open clause - -The prior "revoke the active family or trigger an explicitly documented equivalent -response" language fails a basic interoperability test: two conformant -implementations could respond to the same compromise signal in materially different -ways, and neither would be wrong. This change requires the permitted replay-response set -to be closed, metadata-declared, and discoverable, with each response's effects on the -credential family precisely defined, and requires conformance tests to assert each -declared response. - -This change does not enumerate the specific named responses (e.g., a hypothetical -`revoke_family` vs. `revoke_family_and_require_reauthorization`) as fixed protocol -constants. Picking the exact response vocabulary is downstream drafting work once the -full OAuth-binding prose is written; inventing specific values here would add scope -beyond what any work item's acceptance criteria requires. What this change fixes is the -*shape* of the fix — closed, not open-ended — and the parallel requirement that -refresh-endpoint retransmission after a lost response has a defined outcome, distinct -from replay of an already-superseded generation. - -### Keyless recovery: security-critical policy left unspecified, not a demonstrated bypass - -A prior review characterized an undefined "AS policy event" key-replacement path as a -confirmed bypass. The controlling author response narrows this: it is "security-critical -recovery policy left unspecified," not an existing or demonstrated bypass. This change -follows the narrower, controlling framing throughout — the keyless-recovery requirement -states plainly that it identifies unspecified policy, not an exploited defect, and it -does not assert that any deployment has exercised an undefined recovery path. - -The substantive rule has three parts, mapped directly from the author's three-bullet -formulation: (1) possession of the current key permits ordinary rotation under the -conditions already required for key/instance replacement; (2) recovery without the old -key requires either a fresh, user-facing authorization interaction, or suspension of -issuance followed by an owner-authenticated recovery process and owner notification with -a revocation window — no third path; (3) a public or unregistered client whose only -persistent authenticator was the lost key cannot self-qualify via its own -(re-)authentication. A fourth scenario closes a specific gap: entering a replay-detected -state does not suspend these requirements or license a policy-event bypass. - -The mechanics of "owner-authenticated recovery process" (specific factors, channels) are -deliberately left undefined here — that is deployment/operational detail no work item's -acceptance criteria requires pinning at the protocol level, and pinning it would add -scope beyond what was requested. - -### A minimum credential-security-profile floor is new normative surface - -Prior design discussion presupposed a "minimum credential-security profile" as a grant -fact (a supersession trigger) without ever defining it as a declarable, enforceable -floor. Since `spec-core.md` and `spec-auth-design.md` have no defined term for a -security-profile floor today, the floor's *existence* as a declarable fact is genuinely -new normative surface (labeled "introduces a genuinely new normative capability"), while -the AS-refusal, RS-rejection, and mandatory-disclosure rules that follow from it repair -a real gap already implied by the presupposed grant fact (labeled "repairs an existing -interoperability/security hole"). - -The floor is a manifest-level (source-level) field, not a per-stream field: credential- -security posture is a property of the resource server guarding a source's data, not of -any individual stream's schema. The four resulting requirements are deliberately kept -separate — declaration, AS-refusal, RS-rejection, mandatory disclosure — because each is -independently testable and each obligates a different party (source author, AS, RS, -consent UI); collapsing them into one requirement would obscure who is obligated to do -what. - -This change deliberately does not mint the binding-specific profile identifiers a future -OAuth binding would need (for example, a DPoP-only label or a bearer-compatibility-mode -label). The requirements are written in binding-neutral vocabulary ("presentation mode," -"reduced theft-resistance," "sender-constrained" vs. "weaker mode") so the mechanism is -meaningful today under bearer-only presentation without deciding DPoP's -mandatory-to-implement status or minting a profile-identifier registry this change does -not own. - -**Alternative considered:** leaving the floor as informative guidance (as the current -Section 10 "Sender-constrained tokens" note does) rather than a MUST-level mechanism. -Rejected: informative guidance gives a declaring source no enforceable guarantee, which -is exactly the gap the controlling author response calls "more than an editorial -correction." - -### The 0.1 Migration Profile: named-weaker-mode acceptance, not silent or indefinite - -Prior design discussion cited a "PDPP OAuth 0.1 Migration Profile" without ever -delivering it as a normative artifact, leaving an unresolved conflict between the -hardened, fail-closed common enforcement algorithm and a `legacy_0_1` context whose -issuer, audience, proof-mode, and source-digest fields are marked "unavailable — not -invented." Five candidate resolutions were on the table: reject outright; accept under -v0.1 bearer rules; accept only under a named weaker conformance mode; require operator -approval; or refuse after a stated date. - -This change adopts "accept under a named, discriminated weaker mode (`legacy_0_1`), -bounded by an explicit discovery flag and an operator-controlled sunset." A pure -fail-closed default would strand every pre-existing v0.1 credential the moment an RS -adopts the hardened algorithm — an unacceptable side effect for a migration-ready -profile. Silent, indefinite acceptance is exactly the failure mode a discoverable -legacy-acceptance flag exists to prevent. Operator approval and a stated sunset date are -not alternatives to naming the mode; they are folded in as the disable/sunset mechanism, -since the requirement is for a bounded mechanism as one component of the profile, not a -competing top-level outcome. - -A key structural move: the fields Section 15.9-style migration discussion marks -"unavailable — not invented" (issuer, exact audience, proof/binding mode, security -profile, source-declaration digest) are kept textually separate from fields already -present on a v0.1 grant today (client, subject, source, grant-ID/digest, lifecycle -state). This lets the profile state two clean, non-overlapping rules instead of a long -per-field table: unavailable-by-design fields are accepted without fabrication under -`legacy_0_1`; every other field keeps its existing fail-closed obligation, unchanged. - -This change does not mint any `urn:pdpp:...:0.2` identifier or a `"0.2"` version string, -per the hard scope constraint. Where the profile needs to distinguish `legacy_0_1` from -everything else, it uses binding-neutral phrasing ("the current binding," "a context not -discriminated as `legacy_0_1`," "sender-constrained (key-bound) security profile") -rather than any specific profile-identifier string. - -The migration inventory is extended beyond grants, bearer tokens, `manifest_version`, -and clients to explicitly name a rule for owner device-flow tokens, null-expiry -continuous-grant client access tokens, non-rotating legacy refresh tokens, and extension -token kinds (package-scoped tokens and similar) — every credential kind the credential -inventory confirms exists in the reference implementation. Every "current behavior" -claim in this change is grounded in that credential inventory's file:line evidence, not -in any assertion about a specific live deployment's grant counts or failure modes; this -change does not require, and does not perform, independent verification of any live -deployment's grant inventory. - -**Alternative considered:** fail-closed by default for any context missing a -current-binding field. Rejected: this treats every pre-existing v0.1 grant as broken the -moment a hardened RS is deployed, which contradicts the goal of a migration-ready -profile. - -**Alternative considered:** accept `legacy_0_1` contexts silently and indefinitely with -no discovery signal. Rejected: this is the specific failure mode ("silent, -indefinite acceptance") this change's discovery-flag requirement exists to close. - -### Why the security floor mechanism ships without deciding DPoP MTI status - -The credential-security-floor requirements (declaration, AS refusal, RS rejection, -disclosure) are written to apply *conditionally* — "when a source declares a floor," -"when a deployment permits both a stronger and a weaker mode" — precisely so they do not -themselves decide whether any sender-constrained presentation mode is mandatory to -implement. A source may declare a floor today even though bearer-token presentation is -the only implemented presentation mode in the current reference, and may equally choose -not to declare one. This lets the floor mechanism (and the parallel DPoP duty-split -requirement) land now, while the separate question of whether DPoP adoption itself is -required for any class of deployment remains an experiment prerequisite deferred to the -decomposition gated on the seam-spike. - -### DPoP duty split and private_key_jwt: precision fixes, not new adoption mandates - -RFC 9449 §§4-7 already assign per-request DPoP proof validation (`htm`, `htu`, `iat`, -`ath`, replay/freshness via `nonce`/`jti`) to the resource server, validated against the -actual request. This change makes that division explicit rather than leaving it -implicit behind an unelaborated RFC 9449 citation, and names `ath` explicitly as a -validated claim (previously absent from any explicit list). The introspection response -is limited to supplying `status` and `cnf`; it does not itself validate a request- -specific proof, because the request-specific proof only exists at the point of the -concrete HTTP request, never at the introspection endpoint. Any resolver contract -signature carrying a `presentation_proof` parameter alongside the credential is -misleading under this duty split unless it is dropped, or retained with an explicit -statement that the resolver forwards it only for logging/diagnostic purposes. - -`private_key_jwt` as the client-authentication method for an RS acting as an -introspection caller is attributed to OpenID Connect Core §9 (which defines the method -name and semantics) and the IANA OAuth client-authentication-method registry, not to RFC -7523 alone — RFC 7523 defines only the underlying JWT-bearer assertion mechanism, not -the named method. The profile for an RS using `private_key_jwt` pins the required -assertion audience, required claims, and the RS's credential-registration mechanism with -the AS, so two implementers cannot both claim conformance while producing -non-interoperable assertions. - -### Canonicalization: pin string representation, not the digest algorithm - -RFC 8785 (JSON Canonicalization Scheme) canonicalizes JSON structure but treats string -values as opaque: `...00Z` and `...00.000Z` represent the same instant but canonicalize -to different bytes. "Byte-identical digest" is undefined without pinning exactly one -timestamp string representation, and similarly for durations. This change pins both a -JSON Schema dialect (draft 2020-12 — chosen because nothing in the current spec commits -to an existing dialect, so there is no existing-behavior constraint to preserve, and -2020-12 has the broadest current tooling support) and a canonical timestamp/duration -string profile. - -This change deliberately does **not** define grant-digest computation (which RFC 8785 -application, which hash algorithm, which fields are excluded). That is explicitly -reserved for the decomposition gated on the seam-spike. This change adds only the -string-representation prerequisite that any future digest computation depends on. - -The timestamp profile allows zero or exactly three fractional-second digits (not -mandating always-three) because the spec's own existing timestamp examples already use -a bare `Z` suffix with no fractional seconds; mandating always-three would be a larger -behavior change than the ambiguity requires. The duration profile does not mandate a -single calendar designator (always-days vs. always-months); it requires only that a -producer represent a given duration length consistently within one deployment, since -resolving `P90D` vs. `P3M` canonically would require a real calendar-arithmetic -normalization rule that is closer to the digest-computation surface this change -deliberately excludes. - -### The seam-spike gate: one cross-referenced statement, not several inconsistent ones - -Multiple locations in the prior design discussion describe the seam-spike experiment -with three subtly inconsistent framings (a one-adapter framing, a two-implementation -trigger, and a separate phase-sequencing statement). This change requires exactly one of -those locations to state the experiment definition normatively, with every other -reference cross-referencing it rather than restating a possibly-different definition. - -The repaired protocol itself must: add a 13th corpus vector (a v0.1 grant served through -`legacy_0_1` by a current-binding RS, directly testing this change's migration profile -against the experiment); define "independent" for any two-implementation/two-AS/two-RS -threshold as a separate team or an off-the-shelf product, disallowing oracle -substitution (the same implementation or team serving as both the object under test and -its own evaluating oracle) for the commitment decision; and either give the GNAP adapter -leg real, binding pass/fail criteria (unambiguous partial-approval result, exactly-once -`single_use` behavior, full context resolution) or explicitly declare that leg -non-gating — a mapping-completeness report alone cannot be the GNAP leg's pass criterion -if that leg is declared gating. The seam-spike's first phase depends on the two -canonicalization pins above already being resolved. - -This change states the gate itself as a normative requirement (the decomposition and -schemas are not settled, deferred pending the spike) but does not inline the spike's own -corpus/protocol write-up into spec text; that planning artifact belongs alongside the -existing decision-record documents, not in a normative capability spec. - -## Acceptance Checks - -1. Every requirement below has at least one **WHEN/THEN** scenario and exactly one - **Change class:** line using one of the three approved labels. -2. No requirement asserts a fact about any live deployment (no `pdpp.vivid.fish` claims, - no grant-inventory counts); every "current behavior" claim traces to the credential - inventory's file:line evidence or to a named RFC/OIDC citation. -3. No requirement mints a `"0.2"` version label or a `urn:pdpp:...:0.2` identifier. -4. No requirement reintroduces a same-DPoP-key (or any other) idempotency carve-out for - authorization-code reuse. -5. The `pdpp` supplementary introspection member carries no field already expressible in - `authorization_details`. -6. The keyless-recovery requirement's prose states plainly that it identifies - unspecified policy, not a demonstrated bypass. -7. The floor, DPoP-duty-split, and migration-profile requirements are each phrased - conditionally ("when a source declares...", "when a deployment uses DPoP...", "when - the OAuth binding supports refresh tokens...") and do not themselves decide DPoP's - mandatory-to-implement status. -8. `openspec validate harden-pdpp-authorization-and-0-1-migration --strict` passes. - -## Deferred to PR2 (gated on the seam spike) - -The following remain out of scope for this change and are not settled normative text -until the seam-spike protocol defined in this change's `pdpp-authorization-hardening` -capability has run and passed: - -- The three-document Core/binding decomposition (`spec-core.md` / - `spec-oauth-binding.md` / `spec-owner-profile.md` split). -- The nine closed 0.2 common schemas (`PDPPSelection`, `PDPPApprovedSelection`, - `PDPPRequesterIdentity`, `PDPPConsentEvidence`, `PDPPGrant`, `PDPPGrantState`, - `PDPPCredentialFamily`, `PDPPAuthorizationContext`, `PDPPError`), each at 0.2. -- The multidimensional requester-identity structure (`protocol_client` / - `accountable_entity` / `software_product` / assurance dimensions) and the canonical - grant digest, as fixed, cross-binding-stable objects. -- Any normative GNAP binding or GNAP conformance label. -- Final DPoP mandatory-to-implement status. -- The full conformance-claim label taxonomy beyond what this change's migration profile - and security fixes require. -- URN registries and public URN minting, pending unresolved registry-governance - questions. +PR89 is the executable seam spike and implementation work that tests whether +Source-defined authorization semantics can cross the OAuth/RAR binding and +arrive at a separated resource server as a complete enforcement context. The +sole case definition is `design-notes/seam-spike/corpus.md`. + +The current task is deliberately narrower than the earlier planning draft. It +must test the real database-backed authorization path, authenticated HTTP +introspection, response-only enforcement, exact-once races, and the breaking +persisted-state boundary. It must not turn deferred policy questions into seam +passes. + +## Goals + +- Execute seven deterministic seam cases and one durable-handoff case with + exact fixtures, stable failure codes, receipt JSON, and machine-checkable + oracles. +- Consume the Source contract without creating a second grant schema. +- Prove PostgreSQL atomicity for authorization-code and `single_use` races. +- Test token expiry, instance mismatches, field mismatches, and temporal + bounds in the context and enforcement paths. +- Reject pre-v0.1 persisted authorization state before introspection or route + handling and require fresh consent. +- Make the HTML consent handoff durable across process failure and safely + replayable after a lost response. + +## Non-goals + +- Long-term confidential-client registration or discovery. That belongs to + the separate discovery and trust change. +- Alternate client-registration design. +- DPoP implementation, nonce policy, `jti` policy, or a DPoP mandatory floor. +- Keyless recovery or a minimum credential-security-profile floor. +- Timestamp or duration value canonicalization. JSON Schema dialect ownership + belongs to Source, and value canonicalization waits for a jointly designed + digest algorithm and temporal/duration semantics. +- Persisted-state inventory, device or package token rules, public + compatibility metadata, or a large sunset profile. +- A normative GNAP binding. + +## Key decisions + +### Source owns the resolved authorization shape + +`ApprovedAuthorization` has exactly `source_id`, `access_mode`, and the Source +stream rows. A row requires a unique nonempty `name`, unique nonempty +`instance_ids`, and unique nonempty `fields`. It may contain a frozen temporal +field with optional lower and upper bounds and optional canonical resources. +`source.kind` records provenance only and is outside equality. Binding facts +remain in the resolved context. This prevents an OAuth-shaped grant schema +from becoming a second authority. +The binding maps the Source-neutral +`source.authorization_details_invalid` failure to RFC 9396 +`invalid_authorization_details`. + +### Introspection is real and authenticated + +The spike uses authenticated RFC 7662 HTTP introspection with fixed local +confidential-RS credentials. The RS enforces only from the captured response. +The AS response carries the approved rights once. Supplementary context does +not duplicate them. Registration and discovery are intentionally owned by the +separate discovery and trust change. + +### Lifecycle behavior is concrete + +Authorization codes are one-use with no same-key idempotency exception. A +second redemption returns `invalid_grant`. Whether the already-issued token is +revoked after detected reuse is reported separately at RFC 6749 SHOULD +strength unless exact linkage is implemented and tested. + +Refresh state is a family row keyed by family and generation, with token hash, +active or superseded status, parent generation, and timestamps. Rotation +atomically supersedes the presented generation and creates one successor. +Any superseded-generation reuse, including indistinguishable lost-response +retry, revokes the family and every access bearer linked to it in one +transaction, then returns `invalid_grant`; fresh authorization is required. +The initial authorization-code bearer and every refresh-derived bearer persist +the family id. They expire ten minutes after issuance, capped by any earlier +family expiry. Introspection also verifies that a linked family still has an +active generation, so a family bearer fails closed even if its row was not +individually marked revoked. Refresh is limited to `continuous` grants; a +package is eligible only when every child grant is `continuous`. + +Token responses calculate `expires_in` from the persisted access-token expiry +and omit it when no expiry exists. RFC 7662 responses likewise omit `exp` when +the token has no expiration. SQLite and PostgreSQL fault tests prove that a +failure while revoking family bearers rolls back the family and bearer changes +together. PostgreSQL replay and race tests introspect every family bearer for +grant and package flows. These results have separate tests and receipt fields +and do not expand the seven-case seam decision. + +The new family link cannot be reconstructed honestly for bearer rows created +before the column existed. Bootstrap therefore treats any non-revoked refresh +family with no linked bearer as incompatible authorization state. It revokes +the family and the bearer rows bound to its grant or package, in both SQLite +and PostgreSQL, and requires fresh authorization. Migration tests prove this +fail-closed boundary. They do not backfill a guessed relationship. + +### Pre-v0.1 authorization state is disposable + +PR89 defines a breaking persisted-state boundary, not a compatibility path. +The current persisted-grant reader rejects pre-v0.1 bytes with +`authorization_state.unsupported_legacy_shape` before introspection or route +handling. It does not reconstruct missing authorization or binding facts from +current configuration. There is no acceptance flag, compatibility adapter, or +alternate context kind. Users must complete fresh consent. + +### The post-approval handoff is durable with explicit recovery modes + +Approval and denial are competing terminal decisions over the same pending +authorization. Each backend uses its existing transaction boundary and a +guarded pending-state compare-and-set. The winning decision commits its state, +events, and any credentials together. A competing decision that observed the +row as pending but loses that compare-and-set returns the typed +`approval_conflict` result and cannot emit contradictory terminal evidence. +Later requests for an already-hidden terminal row keep the existing bounded +unavailable response. +This applies to ordinary, batch, and owner-device consent; it does not add a +second decision engine or an intermediate durable status. + +The existing HTML approval path commits the grant and token, then stores its +exchange code in a process-local map. A restart loses that map. If the approval +or exchange response is lost after commit, the client cannot recover the +result. This is a durable handoff defect, not a reason to persist another copy +of the bearer. + +The replacement stores only a hash of the exchange code, an optional hash of a +recovery proof, plus a reference to +`tokens.token_id`, the reference implementation's existing plaintext bearer +authority. It does not persist a second plaintext bearer. Creation and redemption use the configured +database backend. Redemption locks the handoff row, records the first +redemption once. A proofless code is the HTML handoff mode: it is single-use, +and any later redemption fails. A proof-bound code is an out-of-band recovery +mode: the holder of the matching proof may redeem it repeatedly until expiry, +and each successful redemption returns the same grant and token. Missing or +wrong proof fails closed without disclosing the bearer. Proof-bound codes are +never embedded in HTML. This makes response-loss retries idempotent without +persisting a second plaintext bearer. An approval retry for an already-approved request recovers +the same persisted grant and token, so a failure between approval commit and +handoff creation can mint a new bounded exchange code. JSON approval and OAuth +authorization-code responses keep their existing transport. + +SQLite and PostgreSQL tests cover concurrent redemption. A file-backed SQLite +test closes and reopens the database between code creation and redemption to +prove that no process-local state is required. + +### Token responses prevent intermediary caching + +The `/oauth/token` route sets `Cache-Control: no-store` and +`Pragma: no-cache` through one response helper on every successful +token-bearing branch. The helper is used after authorization-code, +refresh-token, and device-code success, so grant and package envelopes share +the same invariant. OAuth errors and unsupported grant responses do not use +the token-success helper. + +### Deferred questions remain visible + +Keyless recovery and the security-profile floor remain in the deferred section +of the execution document because this PR has neither a recovery authority +model nor a profile vocabulary. A future decision on those inputs unlocks the +work. They are not seam prerequisites and cannot count as passes. Temporal and +duration canonicalization is similarly deferred until digest and semantics are +designed together. + +## Acceptance evidence + +The implementation report must show: + +- the exact required commands in the execution document; +- the strict OpenSpec validations; +- all eight case results and their stable oracles; +- PostgreSQL race evidence; +- receipt-checker output from CI-compatible execution; +- a relevant-file tree digest that excludes the receipt itself; +- stale sweeps for removed registration, canonicalization, floor, recovery, + and compatibility language. +- SQLite restart and concurrent SQLite/PostgreSQL exchange-redemption evidence. + +## Ownership and merge order + +| Change | Ownership | Order | +| --- | --- | --- | +| `define-source-declarations-and-resolved-grants` contract | neutral declaration, request, snapshot, and resolved grant contracts | 1 | +| `define-source-declaration-discovery-and-trust` contract | discovery metadata, retrieval, revision, and trust contracts | 2 | +| Source reference implementation | consent snapshots, closed resolved grants, and Source enforcement | 3 | +| Discovery trust reference implementation | discovery storage and accepted-revision consent bridge | 4 | +| `harden-pdpp-authorization-and-0-1-migration` | OAuth/RAR carrier, separated RS, lifecycle and migration gates, durable handoff, and receipts | 5 | + +This change consumes the four preceding Source and discovery layers and must +not define a second grant schema. GNAP and DPoP future work is outside this +program. diff --git a/openspec/changes/harden-pdpp-authorization-and-0-1-migration/proposal.md b/openspec/changes/harden-pdpp-authorization-and-0-1-migration/proposal.md index 357acd192..c53a7c02c 100644 --- a/openspec/changes/harden-pdpp-authorization-and-0-1-migration/proposal.md +++ b/openspec/changes/harden-pdpp-authorization-and-0-1-migration/proposal.md @@ -1,94 +1,77 @@ ## Why -`spec-core.md` Section 8's separated-RS introspection contract omits facts its own -grant-enforcement algorithm requires. The current extension-fields table (`active`, -`pdpp_token_kind`, `subject_id`, `grant_id`, `client_id`, `exp`) does not carry issuer, -exact audience, presentation/proof mode, security-profile provenance, or the projected -streams/fields/time_range/resources the enforcement steps in the same section already -assume the RS has. A resource server in a separated AS/RS deployment cannot run PDPP's -own enforcement algorithm from what introspection is specified to return today. This is -a context hole, not a hypothetical one: Section 8's steps 3-4 reference constraints the -current response table never supplies. - -Alongside the context hole, PDPP's credential lifecycle has three normative gaps: no -stated rule against authorization-code reuse, an open-ended ("or equivalent response") -refresh-replay clause that two conformant implementations could satisfy in materially -different ways, and no rule distinguishing key rotation (old key held) from key recovery -(old key lost) for a continuous credential family. None of these are edge cases; each is -a live decision an implementer must make today with no normative guidance. - -Finally, PDPP v0.1 has no migration profile. `legacy_0_1` authorization contexts are -referenced in prior design discussion as marking issuer, audience, proof-mode, and -source-digest fields "unavailable," but no text reconciles that with the fail-closed, -complete-context enforcement algorithm this change also hardens. Without a migration -profile, an implementer upgrading a resource server has no defined outcome for a -pre-existing v0.1 grant. - -This change hardens PDPP's authorization/credential rules and defines that v0.1 -migration profile. It deliberately does **not** settle the three-document Core/binding -decomposition (`spec-core.md` / `spec-oauth-binding.md` / `spec-owner-profile.md`) or -the nine closed 0.2 common schemas as normative text. Those remain provisional pending a -seam-spike experiment that has not yet run; publishing them now would make that -experiment's fallback option fictional. This change is scoped to the requirements that -hold regardless of the seam-spike's outcome. +The separated resource-server path needs a complete approved authorization and +binding context from authenticated RFC 7662 introspection. The existing +planning material also leaves authorization-code reuse, refresh-token replay, +and the pre-v0.1 persisted-state boundary too open for an executable +implementation. ## What Changes -- Require a complete, authenticated authorization context from separated-RS - introspection: the RFC 9396 `authorization_details` carrier for the approved - selection, plus a minimal `pdpp` supplementary member for grant identity, lifecycle - state, and security profile. Prohibit unauthenticated introspection callers. -- Split DPoP proof-validation duties between the resource server (request-specific - proof: `htm`/`htu`/`iat`/`ath`/`nonce`/`jti`) and the introspection response - (`status` + `cnf` only), and correct the `private_key_jwt` citation to OpenID Connect - Core plus the IANA client-authentication-method registry. -- Require authorization codes to be single-use with no idempotency carve-out, including - no same-DPoP-key exception for retransmission. -- Replace the open-ended refresh-replay response clause with a closed, discoverable - enumeration of permitted responses, and define the refresh-retransmission-after-lost- - response case. -- Define a keyless-recovery rule for continuous credential families: old-key rotation - proceeds under existing conditions; recovery without the old key requires fresh - authorization or a suspend-and-owner-authenticated-recovery path; a public client - cannot self-qualify using only the lost key. -- Add a normative minimum credential-security-profile floor a source can declare, with - mandatory AS refusal and RS rejection below the floor, and mandatory (not optional) - consent disclosure of a permitted weaker presentation mode. -- Define a normative PDPP OAuth 0.1 Migration Profile: dual-mode RS enforcement for - `legacy_0_1` contexts, a discovery flag signaling legacy acceptance, an - operator-controlled disable/sunset mechanism, legacy refresh-token treatment, and a - migration inventory naming every live credential kind. -- Pin one JSON Schema dialect and one canonical timestamp/duration string profile for - schemas and objects that a conformant implementation validates or canonicalizes. -- State, as a single gating fact, that the Core/binding decomposition and the nine 0.2 - common schemas are not settled normative text and remain deferred pending a seam-spike - experiment; specify the corpus, independence definition, and pass/fail criteria that - govern that gate. +- Execute the seven-case OAuth/RAR seam spike plus one durable-handoff case, + the implementation they exercise, and the deterministic receipt checker. +- Define `ApprovedAuthorization` as the Source contract: `source_id`, + `access_mode`, and streams with unique nonempty `name`, `instance_ids`, and + `fields`, optional frozen-field bounds, and optional canonical resources. + `source.kind` is provenance outside authorization equality. +- Require authenticated HTTP introspection with fixed local confidential-RS + credentials for the spike. Long-term registration and discovery belong to + the separate discovery and trust change. +- Make authorization-code one-use and test same-valid-PKCE races. Report token + revocation after detected reuse separately at RFC 6749 SHOULD strength unless + exact token linkage is implemented and tested. +- Implement refresh rotation, superseded-generation reuse, family revocation, + lost-response retry handling, and exact store state following RFC 9700. + Link every family-derived bearer to its family, give it a short persisted + expiry, and atomically deactivate every linked bearer when replay is + detected. Limit refresh to continuous grants and all-continuous packages. + Report truthful token lifetimes and omit absent expiry fields. Report these + controls separately from the seven-case seam result. Revoke legacy refresh + families and their bound bearers when family linkage is absent rather than + reconstructing or guessing the relationship. +- Prevent intermediary caching of every successful token-bearing response + with RFC 6749's `Cache-Control: no-store` and `Pragma: no-cache` headers, + including package variants and device-code responses. +- Replace the process-local HTML consent handoff with a durable exchange-code + record. HTML carries a proofless, single-use code; a separate out-of-band + proof-bound code supports response-loss retry by the same proof holder until + expiry, returning the same persisted token result. +- Make approval and denial one terminal-decision authority. Exactly one + compare-and-set wins, and the winning state, events, and credentials commit + atomically on SQLite and PostgreSQL. +- Treat pre-v0.1 persisted authorization state as disposable. Reject its bytes + before introspection or route handling with + `authorization_state.unsupported_legacy_shape` and require fresh consent. +- Defer keyless recovery, the security-profile floor, DPoP implementation, + timestamp and duration value canonicalization, and persisted-state inventory. +- Keep the GNAP map non-gating and record the five-change ownership and merge + order across Source contract, discovery contract, Source implementation, + discovery implementation and accepted-revision bridge, then this OAuth/RAR + hardening change. ## Capabilities ### New Capabilities -- `pdpp-authorization-hardening` — the authorization-context completeness, credential- - lifecycle, security-floor, and 0.1-migration requirements introduced by this change. +- `pdpp-authorization-hardening`: executable seam, authorization-context + implementation, and deterministic evidence. ### Modified Capabilities -- None. This proposal does not modify any existing OpenSpec capability spec. +- `pdpp-authorization-hardening` is modified by the delta in this change. ### Removed Capabilities -- None. +- Remove the proposed alternate client-registration design, keyless-recovery + rules, security-profile floor, timestamp/duration canonicalization profile, + persisted-state inventory, compatibility adapter, and public compatibility + discovery requirements from PR89 scope. ## Impact -- This is a specification-only change. It adds one new OpenSpec capability - (`pdpp-authorization-hardening`) and does not edit any root `spec-*.md` file. Root - spec edits (`spec-core.md` Sections 5, 8-10, and 12, and a new migration-profile - document) follow in a subsequent change after this proposal is accepted, per this - repository's convention of proposing before editing root specs. -- Out of scope, deferred to a subsequent change gated on the seam-spike's outcome: the - Core/binding document decomposition, the nine closed 0.2 common schemas, the - multidimensional requester-identity structure, the canonical grant digest algorithm, - any normative GNAP binding, final DPoP mandatory-to-implement status, the full - conformance-claim label taxonomy, and public URN minting. +This PR executes the spike and implements the binding, lifecycle, and durable +post-approval handoff behavior needed to pass it. It updates root protocol text +where the implemented OAuth contract requires it. It does not implement a GNAP +binding, DPoP, long-term registration, or public discovery. +The authoritative case, fixture, failure-code, receipt, PostgreSQL, and CI +requirements are in `design-notes/seam-spike/corpus.md`. diff --git a/openspec/changes/harden-pdpp-authorization-and-0-1-migration/specs/pdpp-authorization-hardening/spec.md b/openspec/changes/harden-pdpp-authorization-and-0-1-migration/specs/pdpp-authorization-hardening/spec.md index a0ac83a2f..e2f43137a 100644 --- a/openspec/changes/harden-pdpp-authorization-and-0-1-migration/specs/pdpp-authorization-hardening/spec.md +++ b/openspec/changes/harden-pdpp-authorization-and-0-1-migration/specs/pdpp-authorization-hardening/spec.md @@ -1,522 +1,370 @@ -## ADDED Requirements +## MODIFIED Requirements -### Requirement: Authenticated introspection SHALL return a complete authorization context via RFC 9396 authorization_details plus a minimal pdpp member +### Requirement: A separated resource server SHALL resolve complete context from authenticated introspection -For separated AS/RS deployments, the RS-facing introspection/context-resolution response defined in spec-core.md Section 8 (`### Token introspection`) SHALL be authenticated at the caller level and SHALL return a complete authorization context sufficient to run the grant-enforcement algorithm in Section 8 (`### Grant enforcement`), not the six extension fields (`active`, `pdpp_token_kind`, `subject_id`, `grant_id`, `client_id`, `exp`) currently listed as the complete PDPP extension-fields table. That table omits every constraint the RS's own enforcement steps 3-4 (stream membership, `time_range`, `fields`, `resources`) already require it to have, and omits issuer, audience, and proof/binding context entirely. +The RS SHALL call the AS over authenticated RFC 7662 HTTP using the binding's +configured RS credentials and exact audience. The response SHALL carry the +Source-defined approved authorization through RFC 9396 `authorization_details`. +Supplementary context SHALL carry only binding and lifecycle facts. The RS +SHALL reject missing, unknown, stale, or mismatched mandatory facts before the +route handler and SHALL not perform a second AS lookup. -The normalized, approved selection (the streams/fields/time_range/resources projection the grant actually authorizes) SHALL be carried in the response's `authorization_details` member, using the same RFC 9396 `authorization_details` carrier already normative for the client's authorization request (spec-core.md Section 6), rather than as a second, PDPP-proprietary shadow copy of the same data. RFC 9396 Section 9.2 registers `authorization_details` as a top-level RFC 7662 introspection-response member and Section 14.3 profiles its IANA registration; this specification adopts that standard carrier rather than treating it as PDPP-specific transport. +**Change class:** repairs an existing interoperability and security hole -A separate, small `pdpp` supplementary member on the same introspection response SHALL carry only facts `authorization_details` cannot express: +#### Scenario: A valid response resolves -- immutable grant identity (`grant_id`, `grant_digest`); -- grant lifecycle state (`status`, `consumed_at`, `superseded_by`); -- a consent-evidence reference; -- the security/binding profile in effect for the token being introspected; -- a `cache_until` (or equivalent) bound consistent with the existing `min(token_exp, 60 seconds)` cache ceiling. +- **WHEN** the RS receives an authenticated active response with the exact + audience, unexpired token, matching identity and source facts, and complete + approved rights +- **THEN** it SHALL resolve one authorization context and enforce from that + response -The `pdpp` member SHALL NOT duplicate any field already expressible in `authorization_details` (no shadow copy of streams, fields, time ranges, or resources). +#### Scenario: A response with an expired token fails closed -Beyond the `authorization_details` and `pdpp` members, the response SHALL also carry (as standard RFC 7662 top-level members) `iss` (issuer) and `aud` (the exact resource-server audience), and, when the token's presentation mode is sender-constrained, the key-confirmation (`cnf`) needed to validate the token against the presented proof. Before serving a request, the RS SHALL verify, from this complete context: issuer and exact audience; access-token presentation mode and, where sender-constrained, the proof key via `cnf`; binding/security-profile with no downgrade from what the grant recorded at issuance; client/subject/source/grant-identity (`grant_id`/`grant_digest`) consistency; current grant lifecycle state and cache freshness; and that the requested operation is within the intersection of the request and the returned `authorization_details`. If the response omits any of these required facts, or if it carries a mandatory field or extension the RS does not recognize, the RS SHALL treat the token as unauthorized (fail closed) rather than proceeding with a partial context, consistent with the existing unrecognized-`pdpp_token_kind` fail-closed rule in Section 8. +- **WHEN** introspection reports a token past its expiration at the fixed test + clock +- **THEN** the RS SHALL reject before the route handler with `context.expired` -Unauthenticated introspection callers SHALL be refused. Bearer-only (unauthenticated) access to the introspection/context-resolution endpoint is prohibited; the AS SHALL authenticate the RS as an introspection caller before returning any authorization context. +#### Scenario: A response with an instance mismatch fails closed -**Change class:** repairs an existing interoperability/security hole +- **WHEN** the request instance is not in the approved stream's unique + `instance_ids` +- **THEN** the RS SHALL reject with `context.instance_mismatch` -#### Scenario: Authenticated RS receives a complete authorization context +#### Scenario: Invalid caller or audience fails closed -- **WHEN** an authenticated RS calls the introspection endpoint for a valid, active token -- **THEN** the response includes the normalized approved selection in the RFC-9396-registered `authorization_details` member -- **AND** a distinct `pdpp` member carries only grant identity, lifecycle state, consent-evidence reference, and security/binding profile, with no duplicate copy of the streams/fields/time_range/resources already present in `authorization_details` +- **WHEN** RS introspection credentials are missing or wrong, or `aud` does not + match the RS +- **THEN** the request SHALL be rejected with `context.authentication_failed` + or `context.audience_mismatch` -#### Scenario: An incomplete context fails closed +### Requirement: ApprovedAuthorization SHALL consume the Source contract -- **WHEN** the RS receives a context response missing any of issuer, exact audience, proof-mode/`cnf` (for sender-constrained tokens), binding/security-profile, client/subject/source/grant-identity consistency, current grant state, or cache freshness -- **THEN** the RS SHALL reject the request rather than serve it against a partial context +The resolved authorization SHALL contain exactly `source_id`, `access_mode`, +and `streams`. Each stream SHALL have a unique nonempty `name`, unique +nonempty `instance_ids`, and unique nonempty `fields`. It MAY have +`time_constraint` with a nonempty frozen `field` and at least one bound, and +MAY have unique canonical `resources`. `source.kind` SHALL remain outside +authorization equality, but the binding SHALL reject a kind that does not +match the retained declaration before deriving the resolved authorization. +The granted RFC 9396 detail SHALL also preserve the approved `purpose_code`, +optional `purpose_description`, optional `retention`, and selection +provenance. These policy terms are outside the RS enforcement projection but +remain part of the consent record. JSON Schema dialect ownership remains with +Source. This requirement does not define timestamp or duration value +canonicalization. -#### Scenario: Unrecognized mandatory context field fails closed +**Change class:** repairs an existing interoperability and security hole -- **WHEN** the introspection/context response includes a mandatory extension or constraint the RS does not recognize -- **THEN** the RS SHALL treat the token as unauthorized rather than silently ignore the unrecognized field +#### Scenario: Equivalent validated Source rights are equal -#### Scenario: Unauthenticated introspection is refused +- **WHEN** a persisted grant and approved RAR details contain the same Source + rights and each has passed metadata matching against the retained declaration +- **THEN** they SHALL produce deeply equal `ApprovedAuthorization` values -- **WHEN** a caller attempts to call the introspection/context-resolution endpoint without RS-level authentication -- **THEN** the AS SHALL refuse the call +#### Scenario: Provenance mismatch is rejected before projection -### Requirement: DPoP proof validation duty SHALL be split between the RS and the introspection response, with private_key_jwt correctly attributed +- **WHEN** a persisted grant or approved RAR detail names a `source.kind` that + does not match the retained declaration +- **THEN** the binding SHALL reject it before deriving `ApprovedAuthorization` -Where a deployment uses DPoP (RFC 9449) as a sender-constrained token presentation mode, the resource server alone SHALL validate the DPoP proof against the actual protected-resource request. Per RFC 9449 Sections 4 and 7, the RS's per-request validation SHALL include: `htm` (HTTP method), `htu` (HTTP target URI), `iat` (proof freshness), `ath` (access-token hash binding the proof to the specific presented token), and, where the resource server supplies a nonce, the `nonce` value. RFC 9449 leaves single-use `jti` tracking to server policy rather than mandating it; this specification additionally SHALL require the resource server to maintain a bounded replay cache keyed on `jti` for the accepted proof-freshness window, so that a captured proof cannot be replayed within that window. The introspection/context-resolution response defined above SHALL NOT perform this request-specific validation; it SHALL supply only the token's `status` (via `active`) and key-confirmation (`cnf`) information, from which the RS derives the expected proof key. `ath` SHALL be named explicitly as a validated proof claim; it SHALL NOT be left as an implicit consequence of an unelaborated RFC 9449 citation. +#### Scenario: The carrier preserves approved policy terms -A resolver contract signature that includes a `presentation_proof` parameter alongside the presented credential is misleading under this duty split, because the resolver does not validate the request-specific proof; the request-specific proof exists only at the RS, bound to the concrete HTTP request. Any such resolver/introspection interface described in companion documents SHALL either drop `presentation_proof` from the signature or, if retained, SHALL be accompanied by an explicit statement that the resolver forwards it only for logging/diagnostic purposes and performs no request-proof validation with it. +- **WHEN** the AS narrows and approves an authorization detail +- **THEN** the granted detail SHALL preserve the approved purpose, retention, + and selection provenance without adding them to RS enforcement equality -`private_key_jwt` as the client-authentication method for an RS acting as an introspection/context-resolution caller SHALL be attributed to OpenID Connect Core Section 9, which defines the method name and its semantics as a client-authentication method, together with the applicable IANA OAuth client-authentication-method registry entry. RFC 7523 alone SHALL NOT be cited as attribution for `private_key_jwt`, because RFC 7523 defines only the JWT-bearer client-authentication and authorization-grant assertion mechanism composed underneath it, not the named method or its registration. Where a deployment adopts `private_key_jwt` for the separated-RS-to-AS introspection caller, the applicable profile SHALL pin: the required assertion audience (the AS's token/introspection issuer identifier); the required assertion claims (at minimum issuer, subject, audience, expiration, and a unique assertion identifier suitable for single-use replay rejection); and the RS's credential-registration mechanism with the AS (how the RS's signing key or key set is registered and rotated), so that two independent implementations cannot both claim conformance while producing non-interoperable assertions. +#### Scenario: The OAuth binding maps Source validation failure -**Change class:** repairs an existing interoperability/security hole +- **WHEN** Source validation returns `source.authorization_details_invalid` +- **THEN** the OAuth authorization response SHALL return RFC 9396 + `invalid_authorization_details` -#### Scenario: RS alone validates the request-specific DPoP proof +#### Scenario: Invalid right-bearing values are rejected -- **WHEN** an RS under a DPoP-based presentation mode receives a protected-resource request accompanied by a DPoP proof -- **THEN** the RS SHALL validate `htm`, `htu`, `iat`, `ath`, and replay/freshness (`nonce`/`jti`) against the actual request -- **AND** the introspection/context-resolution response SHALL supply only `status` and `cnf`, and SHALL NOT itself validate the request-specific proof +- **WHEN** a stream has empty or duplicate instance IDs or fields, a duplicate + stream name, malformed bounds, or a changed temporal field +- **THEN** parsing SHALL fail with the corresponding stable `auth.*` code -#### Scenario: presentation_proof is justified or dropped from the resolver contract +#### Scenario: Supplementary context does not duplicate rights -- **WHEN** a resolver/introspection interface contract is specified for DPoP-based deployments -- **THEN** it SHALL either omit a `presentation_proof` parameter from the resolver signature or state explicitly that the resolver does not use it for request-proof validation +- **WHEN** the introspection response is decoded +- **THEN** approved streams, instances, fields, temporal bounds, and resources + SHALL occur in the Source-defined authorization member only -#### Scenario: private_key_jwt is attributed and pinned correctly +### Requirement: Authorization-code redemption SHALL be one-use -- **WHEN** a separated-RS introspection caller authenticates using `private_key_jwt` -- **THEN** the specification SHALL cite OpenID Connect Core Section 9 and the IANA client-authentication-method registry as the attribution for the method, not RFC 7523 alone -- **AND** the applicable profile SHALL pin the required assertion audience, the required assertion claims, and the RS's credential-registration mechanism with the AS +An authorization code SHALL be consumed atomically with the first successful +redemption. Any later redemption, including one with the same valid PKCE +verifier or same DPoP key, SHALL return `invalid_grant` and SHALL not issue a +second token. Revocation after detected reuse is a separate RFC 6749 +SHOULD-strength result unless exact token linkage is implemented and tested. -### Requirement: Authorization codes SHALL be single-use with no retransmission carve-out +**Change class:** repairs an existing interoperability and security hole -An authorization code issued by the OAuth binding SHALL be redeemable exactly once, per RFC 6749 §4.1.2. A second presentation of an already-redeemed authorization code SHALL be denied with `invalid_grant` (or the OAuth-binding-defined equivalent error), regardless of whether the presenting request carries the same DPoP-bound key (`dpop_jkt`) or proof as the original redemption. Same-key proof SHALL NOT repeal the one-use rule; there SHALL be no idempotency carve-out, of any kind, that permits a second presentation of the same code to yield a successful token response. Where technically possible, the AS SHALL revoke the access and refresh tokens already issued from that code's original redemption when a reuse attempt is detected. +#### Scenario: Concurrent redemptions have one winner -A client that fails to receive the token-endpoint response after a successful redemption (a lost response) SHALL NOT recover by re-presenting the same authorization code and expecting a second successful redemption. Lost-response recovery, if a binding chooses to support it, SHALL be defined as a separately named transaction-result or idempotency extension that is bound to the original committed issuance transaction and that never re-triggers grant or credential issuance from the same code. In the absence of such an extension, a lost response after successful redemption SHALL require a fresh authorization interaction to obtain a new code. +- **WHEN** two valid requests redeem one code concurrently in PostgreSQL +- **THEN** exactly one succeeds, exactly one returns `invalid_grant`, and one + token row exists -**Change class:** repairs an existing interoperability/security hole +#### Scenario: A sequential reused code is denied -#### Scenario: A redeemed authorization code is presented a second time +- **WHEN** a valid code is presented after its first successful redemption +- **THEN** the token endpoint SHALL return `invalid_grant` -- **WHEN** a client presents an authorization code that the AS has already successfully redeemed for tokens -- **THEN** the AS SHALL respond with `invalid_grant` (or the OAuth-binding-defined equivalent error) and SHALL NOT issue a new token response -- **AND** the AS SHALL NOT vary this outcome based on whether the second presentation carries proof of the same DPoP-bound key used in the original redemption +### Requirement: Refresh tokens SHALL rotate and detect superseded-generation reuse -#### Scenario: Reuse of a redeemed code triggers revocation of previously issued tokens +The store SHALL retain `family_id`, `generation`, `token_hash`, `status`, +`parent_generation`, `created_at`, and `superseded_at`. A successful use of an +active generation SHALL atomically mark it superseded and insert exactly one +active successor. Family revocation SHALL mark every family row `revoked`. +Reuse of any superseded generation, including a retry after a lost success +response, SHALL revoke the whole family, return +`invalid_grant`, and require fresh authorization. The response SHALL be the +same for all superseded-generation reuse. Every access token issued with or +from the family SHALL persist the family linkage and a token-specific expiry no +more than ten minutes after issuance and no later than the family expiry. +Detected reuse SHALL atomically revoke every family-linked access-token row, +and RFC 7662 introspection SHALL report each one inactive. Refresh tokens SHALL +be issued only for `continuous` grants. A package is eligible only when every +child grant is `continuous`. This follows RFC 9700. -- **WHEN** the AS denies a reused authorization code under the prior scenario -- **THEN** the AS SHALL revoke the access token and, if issued, the refresh token that were produced by that code's original redemption, where technically possible +**Change class:** repairs an existing interoperability and security hole -#### Scenario: Lost-response recovery never performs a second redemption +#### Scenario: Concurrent refresh uses have one successor -- **WHEN** a client fails to receive the token-endpoint response after a redemption that the AS committed -- **AND** no separately defined transaction-result or idempotency extension bound to that committed issuance is in effect -- **THEN** the client SHALL obtain a new authorization code through a fresh authorization interaction rather than re-presenting the original code -- **AND** any re-presentation of the original code SHALL be treated as reuse under the first scenario, not as idempotent retransmission +- **WHEN** two requests use the same active refresh generation concurrently +- **THEN** one rotates successfully and the other revokes the family and + returns `invalid_grant` +- **AND** no active successor SHALL remain after the detected reuse +- **AND** every access token linked to the family SHALL introspect as inactive -### Requirement: Refresh-token replay responses SHALL be a closed, discoverable set, and refresh retransmission SHALL have a defined oracle +#### Scenario: A lost-response retry is not distinguishable -When the OAuth binding supports refresh tokens for a continuous grant's credential family, the AS SHALL rotate the refresh token on every successful use and SHALL invalidate the prior generation. Detection of reuse of an invalidated refresh-token generation (replay) SHALL trigger exactly one response drawn from a closed, metadata-declared, discoverable enumeration of permitted replay responses; a conformant AS SHALL NOT invent or select an undeclared response, and the specification SHALL NOT describe the permitted response set as open-ended or as an "equivalent response" left to implementation discretion. Each enumerated replay response SHALL have precisely defined effects on the credential family's active tokens and lifecycle state. +- **WHEN** the client retries a refresh token after the successful response was + lost +- **THEN** the superseded generation SHALL trigger family revocation and + `invalid_grant`, not an idempotent replay response -The binding SHALL also define the AS's required behavior when a client retransmits the same refresh-token-rotation request after failing to receive the rotation response (a lost response), distinguishing that case from replay of an already-superseded generation. This retransmission rule SHALL give conformance tests a defined expected outcome for the lost-response case; it SHALL NOT be left unspecified. +#### Scenario: Token lifetime fields report persisted truth -**Change class:** repairs an existing interoperability/security hole +- **WHEN** a family-linked access token is issued +- **THEN** `expires_in` SHALL report its actual persisted short lifetime +- **AND** RFC 7662 `exp` SHALL report the same persisted expiration +- **WHEN** an access token has no expiration +- **THEN** the token response SHALL omit `expires_in` and introspection SHALL + omit `exp` -#### Scenario: Reuse of an invalidated refresh-token generation is met with a declared response +#### Scenario: Single-use and mixed-mode packages receive no refresh token -- **WHEN** the AS detects presentation of a refresh token from a generation already superseded by rotation -- **THEN** the AS SHALL apply exactly one response from the closed, metadata-declared enumeration of permitted replay responses -- **AND** the AS SHALL NOT apply any response outside that enumeration +- **WHEN** an authorization-code exchange binds a `single_use` grant +- **THEN** the response SHALL omit `refresh_token` +- **WHEN** a package contains any child grant that is not `continuous` +- **THEN** the response SHALL omit `refresh_token` -#### Scenario: Retransmission of the same rotation request after a lost response has a defined outcome +#### Scenario: Containment failure rolls back atomically -- **WHEN** a client retransmits the identical refresh-token-rotation request because it did not receive the AS's rotation response to its immediately prior request -- **THEN** the AS's required behavior SHALL be the behavior the binding specifies for this case -- **AND** that behavior SHALL be distinguishable, by specification and by test, from the behavior required when a superseded generation is replayed +- **WHEN** revoking a family-linked bearer fails during replay containment +- **THEN** neither the refresh-family revocation nor a partial bearer + revocation SHALL commit +- **WHEN** superseding the active refresh generation fails after bearer + insertion +- **THEN** the inserted bearer SHALL roll back and the active generation SHALL + remain usable -#### Scenario: Conformance tests assert each declared replay response +#### Scenario: Unlinked legacy refresh state fails closed -- **WHEN** conformance tests are derived from the closed replay-response enumeration -- **THEN** each enumerated response SHALL have a corresponding test asserting its declared effect on the credential family +- **WHEN** storage migration finds an active or superseded refresh family with + no persisted family-linked bearer +- **THEN** the migration SHALL NOT guess or reconstruct bearer linkage +- **AND** it SHALL revoke that family and its grant- or package-bound bearer + rows and require fresh authorization -### Requirement: Keyless credential-family recovery SHALL require fresh authorization or owner-authenticated suspend-and-recover, never self-qualification by the lost key alone +### Requirement: Successful token responses SHALL prevent intermediary caching -For a continuous credential family bound to a client-held key (for example, a DPoP-bound family), the OAuth binding SHALL distinguish cryptographic key rotation from keyless recovery and SHALL apply distinct requirements to each: +Every successful `/oauth/token` response containing an access token or refresh +token SHALL set `Cache-Control: no-store` and `Pragma: no-cache` before the +response body is serialized. The requirement applies to authorization-code, +refresh-token, and device-code exchanges, including grant and package variants. +Token errors and unsupported grant responses are outside this successful +token-response requirement. + +**Change class:** repairs an existing standards and credential-handling hole -1. **Rotation (old key possessed).** Possession of the currently active key SHALL permit rotation to a new key under the existing conditions already required for runtime key and instance replacement: the same authenticated protocol client requests the change; the accountable entity and software product are unchanged; the new credential is no broader and carries the same RS audience; the event is recorded in the immutable lifecycle audit; and old-key credentials become inactive within the declared propagation bound. +#### Scenario: Authorization-code and refresh responses prevent caching -2. **Recovery (old key not presented).** When a client requests replacement of a continuous family's key without presenting proof of the currently active key, the AS SHALL require EITHER (a) a fresh, user-facing authorization interaction that re-establishes the grant's authority, OR (b) suspension of further issuance under the affected family, followed by an owner-authenticated recovery process, followed by notification to the owner that includes a bounded revocation window during which the owner may reject the recovery. No third path SHALL satisfy this requirement; in particular, an undefined "AS policy event" or "equivalent strong client reauthentication" SHALL NOT itself constitute sufficient recovery assurance. +- **WHEN** an authorization-code or refresh-token exchange succeeds for a + grant or package +- **THEN** the response SHALL include `Cache-Control: no-store` and + `Pragma: no-cache` -3. **Public clients cannot self-qualify.** When the requesting client is a public or unregistered client whose only persistent authenticator was the key being replaced, that client's own (re-)authentication SHALL NOT itself satisfy the recovery-assurance requirement in item 2; recovery for such a client SHALL proceed only through path (a) or path (b) above, established independently of the lost key. +#### Scenario: Device-code responses prevent caching -This keyless-recovery rule SHALL apply without exception to a credential family that has transitioned to a replay-detected state; entering that state SHALL NOT be treated as a trigger that permits bypassing paths (a) or (b) above. +- **WHEN** a device-code exchange succeeds for an owner or package token +- **THEN** the response SHALL include `Cache-Control: no-store` and + `Pragma: no-cache` + +#### Scenario: Token errors do not masquerade as token successes + +- **WHEN** `/oauth/token` returns an OAuth error or unsupported-grant response +- **THEN** the successful token-response requirement SHALL not apply and the + route SHALL not serialize a token envelope + +### Requirement: Pre-v0.1 persisted authorization state SHALL fail closed + +The current persisted-grant reader SHALL treat pre-v0.1 authorization-state +bytes as unsupported. It SHALL reject them with +`authorization_state.unsupported_legacy_shape` before introspection or route +handling and SHALL require fresh consent. It SHALL NOT reconstruct +`instance_ids`, issuer, audience, source identity, or any other missing fact +from current configuration. This requirement defines no acceptance flag, +compatibility adapter, alternate context kind, persisted-state inventory, +discovery metadata, or sunset policy. + +**Change class:** repairs an existing interoperability and security hole + +#### Scenario: Unsupported persisted bytes fail before authorization use -This rule identifies security-critical recovery policy that v0.1 and the current design left unspecified; it does not assert that any deployment has exercised an undefined recovery path. +- **WHEN** pre-v0.1 authorization-state bytes reach the current persisted-grant + reader +- **THEN** the reader SHALL return + `authorization_state.unsupported_legacy_shape` before introspection or route + handling +- **AND** the user SHALL be required to complete fresh consent -**Change class:** repairs an existing interoperability/security hole +#### Scenario: Missing facts are not reconstructed + +- **WHEN** the unsupported bytes omit facts required by the current contract +- **THEN** the reader SHALL return + `authorization_state.unsupported_legacy_shape` +- **AND** it SHALL NOT obtain missing facts from current configuration -#### Scenario: Rotation with old-key proof proceeds under the existing conditions +### Requirement: Approval and denial SHALL have one terminal outcome -- **WHEN** a client presents proof of the currently active key together with a request to rotate to a new key -- **AND** the same authenticated protocol client, unchanged accountable entity and product, no-broadening, and same-audience conditions all hold -- **THEN** the AS SHALL permit the rotation, record the event in the immutable lifecycle audit, and require old-key credentials to become inactive within the declared propagation bound +For ordinary, batch, and owner-device consent, approval and denial SHALL use a +guarded transition from the same pending authorization. Exactly one decision +SHALL win. The winning terminal state, its events, and any issued credentials +SHALL commit in one SQLite or PostgreSQL transaction. A competing decision that +observed `pending` but loses the compare-and-set SHALL fail with +`approval_conflict` and SHALL NOT emit contradictory terminal events or return +a success response. A later lookup of an already-hidden terminal row MAY retain +the existing unavailable response. + +#### Scenario: Approval wins the terminal decision -#### Scenario: Keyless recovery requires fresh authorization or suspend-plus-owner-recovery +- **WHEN** approval commits before a competing denial claims the pending row +- **THEN** the authorization and its credentials SHALL remain active +- **AND** denial SHALL fail with `approval_conflict` +- **AND** no denial event SHALL be stored -- **WHEN** a client requests key replacement for a continuous credential family without presenting proof of the currently active key -- **THEN** the AS SHALL require either a fresh user-facing authorization interaction, or suspension of issuance under that family followed by an owner-authenticated recovery process and owner notification with a revocation window -- **AND** the AS SHALL NOT complete the key replacement through any other path +#### Scenario: Denial wins the terminal decision -#### Scenario: A public client cannot recover using only the lost key as its own authenticator +- **WHEN** denial commits before a competing approval claims the pending row +- **THEN** approval SHALL fail with `approval_conflict` +- **AND** no grant, package, or owner bearer SHALL be issued +- **AND** exactly one denial event SHALL be stored -- **WHEN** the requesting client is a public or unregistered client whose sole persistent authenticator was the key being replaced -- **THEN** that client's self-authentication SHALL NOT satisfy the recovery-assurance requirement -- **AND** recovery SHALL proceed only via fresh user authorization or the suspend-plus-owner-authenticated-recovery path +#### Scenario: A terminal event write fails -#### Scenario: Replay-detected state does not create a recovery bypass +- **WHEN** a terminal event cannot be stored before transaction commit +- **THEN** the decision state and every credential write SHALL roll back +- **AND** the authorization SHALL remain pending -- **WHEN** a credential family has transitioned to a replay-detected state -- **THEN** the keyless-recovery requirements of this rule SHALL still apply in full -- **AND** no policy event SHALL substitute for fresh authorization or the suspend-plus-owner-authenticated-recovery path +### Requirement: Post-approval handoff SHALL be durable and use explicit recovery modes -### Requirement: A source declaration SHALL be able to declare a minimum credential-security profile +The consent exchange path SHALL store exchange-code state in the configured +database, not in process memory. The store SHALL retain only a non-reversible +code hash, an optional non-reversible recovery-proof hash, and a reference to +`tokens.token_id`, the reference implementation's existing plaintext bearer +authority. It SHALL NOT persist a second plaintext bearer. The first +successful redemption SHALL record its transition atomically. A proofless code +is the HTML handoff mode and SHALL be single-use: any later redemption SHALL +fail. A proof-bound code SHALL only be created and delivered out of band; the +holder of the matching proof MAY redeem it repeatedly until expiry, and each +successful redemption SHALL return the same grant and token result without +issuing another token. Missing or wrong proof SHALL fail closed without +revealing the bearer. An already-committed approval SHALL be resumable so a +failure before handoff delivery can create a fresh bounded exchange code. +Expired and unknown codes SHALL fail closed. JSON approval and OAuth +authorization-code transport SHALL remain unchanged. -A source declaration (the manifest described in Section 5, Manifest Format) SHALL be able to declare a minimum credential-security profile: a floor stating the weakest class of access-token presentation the source's protected resource will accept. This is new normative surface. Nothing in the current v0.1 manifest fields table (Section 5, Manifest fields) carries any such floor, and Section 10's existing bearer-vs-sender-constrained discussion ("Sender-constrained tokens (informative)") is SHOULD-level and does not let a source require anything. +**Change class:** repairs an existing durability and credential-delivery hole -The floor SHALL be a manifest-level field, not a per-stream field: credential-security posture is a property of the resource server guarding the source's data, not of an individual stream within it. A manifest that declares no minimum credential-security profile SHALL be treated as declaring no floor (the v0.1 baseline: bearer-token presentation, RFC 6750, remains acceptable). +#### Scenario: Process failure does not lose an exchange result -This requirement defines only the existence, scope, and binding-independent meaning of the floor as a declarable fact. It does NOT define the concrete set of named credential-security profile identifiers (for example, an OAuth-binding-specific DPoP-only label or a bearer-compatibility-mode label): those identifiers are binding-specific and are out of scope for this profile-independent core requirement. It also does NOT decide whether any sender-constrained presentation mode is mandatory to implement in general; a source MAY declare a floor today even though, in the current reference, bearer-token presentation is the only implemented presentation mode, and MAY choose not to declare one. +- **WHEN** the process restarts after the HTML exchange code is stored and + before the code is redeemed +- **THEN** the client SHALL redeem the code from the reopened database and + receive the approved grant and existing token -**Change class:** introduces a genuinely new normative capability +#### Scenario: A proof-bound lost redemption response is safely retried -#### Scenario: A manifest declares a minimum credential-security profile +- **WHEN** the first redemption of an out-of-band proof-bound code commits but + its response is lost +- **THEN** a retry of the same unexpired code with the matching proof SHALL + return the same grant and token +- **AND** it SHALL NOT issue or persist a second token +- **AND** a retry with a missing or wrong proof SHALL fail with a bounded + bearer-free error + +#### Scenario: A proofless HTML code is single-use -- **WHEN** a source's manifest declares a minimum credential-security profile -- **THEN** that declaration SHALL apply to every stream served under that source's protected resource -- **AND** the declaration SHALL be visible to both the authorization server and the resource server enforcing that source, not private configuration known only to one +- **WHEN** a caller redeems a code embedded in the HTML approval response +- **THEN** the first redemption SHALL return the approved grant and token +- **AND** a later redemption, with or without a proof, SHALL fail with a + bounded bearer-free error +- **AND** the HTML response SHALL NOT contain a recovery proof + +#### Scenario: Approval-to-handoff failure is recoverable -#### Scenario: Absence of a declared floor preserves the v0.1 baseline +- **WHEN** approval is committed but the process fails before a handoff code is + delivered +- **THEN** retrying that approval SHALL recover the committed grant and token + and create a new bounded exchange code -- **WHEN** a source's manifest declares no minimum credential-security profile -- **THEN** bearer-token presentation (RFC 6750) SHALL remain an acceptable presentation mode for that source's grants -- **AND** no requirement in this section SHALL be read as retroactively requiring a floor where none is declared +#### Scenario: Concurrent proof-bound redemption converges + +- **WHEN** two requests redeem the same valid proof-bound exchange code with + the matching proof concurrently +- **THEN** both SHALL observe the same grant and token result +- **AND** exactly one first-redemption transition SHALL be stored -### Requirement: The authorization server SHALL refuse to issue below a declared credential-security floor +#### Scenario: Concurrent proofless redemption has one winner -When a source declares a minimum credential-security profile, the authorization server SHALL refuse to issue an authorization or access token whose presentation mode is weaker than that declared floor. This upgrades the presupposed but previously undefined gate: the current design implies a policy check at authorization time but states no obligation level for it, and provides the declaring source no enforceable guarantee. The authorization server MUST NOT downgrade an already-issued grant's presentation mode below the floor declared at issuance time, either. +- **WHEN** two requests redeem the same valid proofless exchange code + concurrently +- **THEN** exactly one SHALL receive the approved grant and token +- **AND** every losing request SHALL receive a bounded bearer-free error +- **AND** exactly one first-redemption transition SHALL be stored -Refusal SHALL use the ordinary grant/token error path (an authorization or token error indicating the requested presentation mode is not permitted for the source), not a silent downgrade and not a silent issuance under the weaker mode. +### Requirement: The seam result SHALL remain bounded and receipt-verifiable -**Change class:** repairs an existing interoperability/security hole +The authoritative execution document is +`design-notes/seam-spike/corpus.md`. It SHALL define exactly seven seam cases +and one durable-handoff case, +fixture paths, stable failure codes, commands, receipt schema, and deterministic +oracles. PostgreSQL SHALL be mandatory for code and `single_use` races. CI +SHALL run the receipt checker. The receipt's relevant-file tree digest SHALL +exclude the receipt itself. Remaining proposed common schemas SHALL remain +undecided. -#### Scenario: The authorization server rejects a below-floor authorization request +**Change class:** repairs an existing interoperability and security hole -- **WHEN** a client requests authorization selecting a presentation mode weaker than the source's declared minimum credential-security profile -- **THEN** the authorization server SHALL refuse to issue the authorization or access token for that request -- **AND** SHALL return an error rather than silently issuing under the weaker mode or silently upgrading the request to the declared floor +#### Scenario: The eight cases produce a complete receipt -#### Scenario: A source with no declared floor imposes no refusal obligation +- **WHEN** the strict target and receipt checker run +- **THEN** all eight case results, the PostgreSQL assertion, the three decision + keys, and the undecided common-schema result SHALL be present -- **WHEN** a client requests authorization against a source whose manifest declares no minimum credential-security profile -- **THEN** this requirement SHALL NOT obligate the authorization server to refuse any presentation mode on that basis +#### Scenario: Deferred controls are not seam passes -### Requirement: The resource server SHALL reject authorization contexts below its declared floor +- **WHEN** the receipt reports keyless recovery, security-profile floor, DPoP, + or other deferred controls +- **THEN** it SHALL mark them deferred or not demonstrated rather than passed -When a source declares a minimum credential-security profile, the resource server enforcing that source SHALL reject a request whose resolved authorization context reports a presentation mode weaker than the declared floor. This is a MUST-level obligation, not a MAY: the resource server's enforcement is the last line of defense against a token that was issued (whether through authorization-server error, a downstream compromise, or a context resolved under a different, permissive source's rules) below the floor the source itself requires. +#### Scenario: GNAP remains non-gating -This rejection SHALL be independent of, and in addition to, the authorization server's issuance-time refusal in the preceding requirement: an authorization server refusing to issue below the floor does not relieve the resource server of its own obligation to check the resolved context at request time, per the existing Trust boundary responsibilities division of labor (Section 10) under which the resource server never re-validates beyond introspection but does enforce what introspection reports. - -A declared floor SHALL take precedence over legacy acceptance. Where a source declares a minimum credential-security profile and a presented credential resolves to a legacy context whose presentation mode is unknown or unattested, the resource server SHALL treat that context as below the floor and reject it, even while the deployment's legacy-acceptance signal is otherwise active. Legacy acceptance SHALL widen enforcement only for sources that have declared no floor; it SHALL NOT be read as a blanket exemption from a floor a source has declared. - -**Change class:** repairs an existing interoperability/security hole - -#### Scenario: The resource server rejects a request presented below the declared floor - -- **WHEN** a resource server resolves an authorization context whose presentation mode is weaker than its source's declared minimum credential-security profile -- **THEN** the resource server SHALL reject the request -- **AND** SHALL NOT serve the request under the assumption that the authorization server's issuance-time check already covered this case - -#### Scenario: A correctly floored context is served normally - -- **WHEN** a resource server resolves an authorization context whose presentation mode meets or exceeds its source's declared minimum credential-security profile -- **THEN** the resource server SHALL proceed with ordinary grant enforcement (stream membership, field projection, time_range, resources) as already specified in Section 8 - -#### Scenario: A declared floor overrides active legacy acceptance - -- **WHEN** a deployment has legacy acceptance active and a resource server resolves a legacy context whose presentation mode is unknown or unattested, for a source that declares a minimum credential-security profile -- **THEN** the resource server SHALL reject the request as below the floor -- **AND** SHALL NOT treat the active legacy-acceptance signal as an exemption from the declared floor - -### Requirement: Consent disclosure of a reduced-theft-resistance presentation mode SHALL be mandatory - -When a source permits a weaker, reduced-theft-resistance presentation mode (for example, a bearer-compatibility mode alongside a stronger sender-constrained mode) as one of the modes available for its grants, the authorization server's consent surface SHALL disclose that the selected or selectable weaker mode carries reduced theft-resistance compared to the source's stronger mode, before the user completes authorization. This is a MUST-level obligation. It replaces any framing under which such disclosure is merely optional UI wording: leaving disclosure optional is what allows a client to obtain a materially weaker presentation mode against a source the user believes they are protecting under a stronger one, with the consent surface saying nothing about the difference. - -This disclosure requirement composes with, and does not substitute for, the two preceding requirements: a source that declares a minimum credential-security profile excludes the weaker mode entirely (no disclosure question arises, because the mode is refused outright); this disclosure requirement governs the remaining case where a source permits both modes and a weaker mode is nonetheless selectable. - -**Change class:** repairs an existing interoperability/security hole - -#### Scenario: A weaker presentation mode triggers mandatory disclosure - -- **WHEN** a source permits both a stronger sender-constrained presentation mode and a weaker, reduced-theft-resistance presentation mode, and an authorization request would result in the weaker mode being issued -- **THEN** the authorization server's consent surface SHALL disclose the reduced theft-resistance of the weaker mode to the user before authorization completes -- **AND** the authorization server SHALL NOT complete issuance of the weaker mode without having displayed that disclosure - -#### Scenario: A floor excludes the weaker mode outright, so no disclosure question arises - -- **WHEN** a source declares a minimum credential-security profile that excludes the weaker presentation mode -- **THEN** the weaker mode SHALL already be refused under the authorization-server-refusal requirement above -- **AND** this disclosure requirement imposes no additional obligation for that source, since the weaker mode is never issued - -### Requirement: A normative PDPP OAuth 0.1 Migration Profile SHALL resolve the legacy-context enforcement conflict - -A normative PDPP OAuth 0.1 Migration Profile SHALL exist as a deliverable of this change, resolving the previously undefined conflict between the common RS enforcement algorithm (Section 8's grant-enforcement steps, which resolve a complete authorization context and fail closed on unknown mandatory constraints) and a `legacy_0_1` authorization context (Section 15.9 item 2 of the controlling architecture decision), whose issuer, exact audience, proof/binding mode, and source-declaration digest are explicitly marked unavailable rather than invented. Prior to this Migration Profile, no spec text stated which of the two rules governs a `legacy_0_1` context, and the required backward-compatibility conformance test had no definable expected result. - -The Migration Profile SHALL define exactly one enforcement outcome for a `legacy_0_1` authorization context, distinct from the fail-closed algorithm applied to a context asserting the current binding: - -- A `legacy_0_1` context SHALL be recognized only when the RS resolves it as `legacy_0_1` explicitly (a discriminated context kind), never inferred from the mere absence of fields on an otherwise-current-binding context. -- For the specific fields that Section 15.9 item 2 marks unavailable — issuer, exact RS audience, access-token presentation/proof mode, binding and security-profile identity, and source-declaration digest — the RS SHALL accept their absence under the `legacy_0_1` context kind and SHALL NOT fail closed solely because those fields are absent. -- For every other field the common algorithm requires — client, subject, source, and grant-ID/digest consistency; current grant lifecycle state; and cache freshness — a `legacy_0_1` context SHALL be resolved and enforced exactly as the common algorithm requires; these facts are present on a v0.1 grant and their absence, staleness, or mismatch SHALL fail closed under the common algorithm, unchanged by legacy-context handling. -- An RS resolving a `legacy_0_1` context SHALL NOT treat that context as satisfying, or silently substitute it for, an authorization context asserting a sender-constrained (key-bound) security profile; a `legacy_0_1` context SHALL carry only the security properties of an unauthenticated bearer credential (RFC 6750) for authorization purposes. -- A `legacy_0_1` context is accepted only while the deployment's discovery metadata affirmatively signals legacy acceptance (see the following requirement) and only until any operator-set sunset boundary is reached (see the disable/sunset requirement below); outside those bounds the RS SHALL reject the context. - -This closes the gap in which a `legacy_0_1` context's declared-missing fields could otherwise be read as satisfying the common algorithm's fail-closed-on-unknown-constraint rule in either direction (over-strict rejection of every existing v0.1 grant, or silent unbounded acceptance). - -**Change class:** repairs an existing interoperability/security hole - -#### Scenario: A legacy context with the expected missing fields is accepted under legacy rules - -- **WHEN** an RS resolves an authorization context that is explicitly discriminated as `legacy_0_1` and whose issuer, exact audience, proof/binding mode, security profile, and source-declaration digest are marked unavailable -- **THEN** the RS SHALL NOT reject the context solely for the absence of those specific fields -- **AND** the RS SHALL still verify client, subject, source, and grant-ID/digest consistency, current grant lifecycle state, and cache freshness, and SHALL fail closed if any of those checks fails - -#### Scenario: A legacy context is never mistaken for a sender-constrained context - -- **WHEN** an RS resolves a `legacy_0_1` authorization context -- **THEN** the RS SHALL NOT grant that context any of the security properties associated with a sender-constrained (key-bound) presentation mode -- **AND** the RS SHALL treat the presenting credential as an unauthenticated bearer credential for authorization purposes only - -#### Scenario: A context missing required facts outside the declared legacy set still fails closed - -- **WHEN** an RS resolves an authorization context that is not explicitly discriminated as `legacy_0_1` and that is missing any field the common algorithm requires -- **THEN** the RS SHALL fail closed per the common algorithm's unknown-mandatory-constraint rule -- **AND** the absence of that field SHALL NOT be reinterpreted as an implicit `legacy_0_1` context - -### Requirement: An RS SHALL support dual-mode enforcement of v0.1-legacy and current authorization contexts simultaneously - -A resource server MAY, and where it accepts any v0.1-issued credential MUST, support two concurrently active enforcement paths distinguished by the discriminated context kind resolved for a given request: the `legacy_0_1` path defined above, and the common algorithm applied to a context asserting the current binding. A single RS deployment SHALL be able to serve both a client presenting a pre-existing v0.1 credential and a client presenting a credential issued under the current binding within the same deployment lifetime, without requiring a deployment-wide cutover. - -The RS SHALL determine which path applies solely from the resolved authorization context's discriminated kind, never from token syntax, never from the presence or absence of an `Authorization` header scheme, and never from client-declared version headers alone. - -Neither path SHALL widen the other's guarantees: resolving a request under the `legacy_0_1` path SHALL NOT grant access broader than the specific grant's own stored constraints (streams, fields, time range, resources, access mode) permit, and the existence of the `legacy_0_1` path SHALL NOT relax any enforcement step of the common algorithm for a context not discriminated as `legacy_0_1`. - -**Change class:** repairs an existing interoperability/security hole - -#### Scenario: The RS enforces both paths in the same deployment without cutover - -- **WHEN** a deployment holds both a pre-existing v0.1 grant and a grant issued under the current binding, and both are presented for enforcement in the same operating period -- **THEN** the RS SHALL resolve and enforce each independently under its own path -- **AND** SHALL NOT require disabling one path to serve requests under the other - -#### Scenario: Path selection never depends on token syntax or headers alone - -- **WHEN** the RS selects which enforcement path applies to an incoming request -- **THEN** the selection SHALL be based solely on the discriminated kind of the resolved authorization context -- **AND** SHALL NOT be based on the token's wire format, the presence of a version request header, or any other client-supplied signal alone - -### Requirement: A deployment's discovery metadata SHALL explicitly signal whether v0.1 legacy acceptance is active - -An AS or protected-resource metadata document SHALL advertise, via an explicit discovery flag, whether the deployment currently accepts `legacy_0_1` authorization contexts, following the existing `pdpp_*_supported`-style discovery convention (e.g. `pdpp_registration_modes_supported`). Absence of this flag, or its explicit value `false`, SHALL be treated as legacy acceptance being off; a deployment SHALL NOT accept a `legacy_0_1` context merely because the flag is unset, and silent indefinite acceptance inferred only from an RS's willingness to resolve older tokens is prohibited. - -When the flag indicates legacy acceptance is active, the discovery metadata SHALL also surface the sunset boundary in effect, if any, per the disable/sunset requirement below, so that clients and auditors can determine the acceptance window without an out-of-band inquiry. - -**Change class:** repairs an existing interoperability/security hole - -#### Scenario: A client checks discovery metadata before relying on legacy acceptance - -- **WHEN** a client inspects a deployment's discovery metadata -- **THEN** it SHALL find an explicit flag stating whether `legacy_0_1` contexts are currently accepted -- **AND** if accepted, SHALL find the sunset boundary in effect, if the operator has set one - -#### Scenario: Absence of the flag means legacy acceptance is off, not silently on - -- **WHEN** a deployment's discovery metadata omits the legacy-acceptance flag, or sets it to false -- **THEN** the RS SHALL reject `legacy_0_1` authorization contexts -- **AND** SHALL NOT infer acceptance from having previously served v0.1-issued credentials - -### Requirement: An operator SHALL have a bounded, explicit mechanism to disable acceptance of v0.1 legacy credentials - -A deployment SHALL provide an operator-controlled mechanism to stop accepting `legacy_0_1` authorization contexts and any bearer-mode credential accepted only under the legacy/bearer profile, bounded by an explicit and discoverable sunset condition (a stated date, an operator action, or both). This mechanism specifically addresses the two legacy-credential properties that otherwise carry no natural expiry: a grant with a null (`continuous`) `expires_at`, and a non-rotating legacy refresh token (see the refresh-token requirement below), neither of which lapses on its own. - -Once the operator has disabled legacy acceptance (whether by reaching a stated sunset date or by an explicit operator action), the RS SHALL reject every subsequent request presenting a `legacy_0_1` context or a legacy-profile bearer credential, and the deployment's discovery metadata SHALL reflect the disabled state per the discovery requirement above. Disabling legacy acceptance SHALL NOT itself delete, rewrite, or reissue the underlying v0.1 grant records; a disabled legacy grant remains an immutable historical record, reachable again only if the owner performs a fresh authorization interaction that supersedes it under the current binding. - -**Change class:** repairs an existing interoperability/security hole - -#### Scenario: An operator disables legacy acceptance and it takes effect deployment-wide - -- **WHEN** an operator invokes the disable mechanism, or a previously configured sunset date is reached -- **THEN** the RS SHALL reject all subsequent `legacy_0_1` contexts and legacy-profile bearer credentials -- **AND** the discovery metadata's legacy-acceptance flag SHALL reflect the disabled state on the next metadata fetch - -#### Scenario: A null-expiry legacy grant is not left permanently acceptable by default - -- **WHEN** a `continuous` v0.1 grant with a null `expires_at` is the only credential asserting access to a source -- **THEN** the deployment SHALL have an available, documented operator path to bound or end that grant's acceptance -- **AND** that path SHALL NOT depend on the grant's own `expires_at` ever becoming non-null - -#### Scenario: Disabling legacy acceptance does not mutate historical grant records - -- **WHEN** legacy acceptance is disabled for a deployment -- **THEN** the stored bytes of any pre-existing v0.1 grant SHALL remain unchanged -- **AND** the grant SHALL be reachable again only through a fresh authorization interaction that supersedes it under the current binding - -### Requirement: A legacy v0.1 refresh token SHALL be neither silently reclassified nor silently upgraded - -A refresh token issued under v0.1 (a non-rotating, reusable refresh token, per the credential inventory's confirmed absence of rotation-on-use or reuse detection for this credential kind) SHALL continue to be honored, if at all, only under the legacy/bearer profile it was issued under. An RS or AS SHALL NOT: - -- treat presentation of a legacy refresh token as if it were bound to a sender-constrained (key-bound) presentation mode; or -- silently begin applying rotation-on-use or reuse-detection semantics to a legacy refresh token as though it had been reissued under the current binding, without an explicit reauthorization event that supersedes the underlying grant. - -A legacy refresh token remains subject to the disable/sunset mechanism above: once legacy acceptance is disabled for a deployment, a legacy refresh-token exchange SHALL be rejected. Upgrading a client from a legacy refresh token to a current-binding refresh token SHALL occur only through an explicit fresh authorization interaction that supersedes the prior grant; an AS SHALL NOT transparently exchange a legacy refresh token for a current-binding refresh token as a byproduct of ordinary use. - -**Change class:** repairs an existing interoperability/security hole - -#### Scenario: A legacy refresh token is not silently upgraded on use - -- **WHEN** a client presents a v0.1-issued refresh token to the token endpoint -- **THEN** the AS SHALL process the exchange, if at all, under the legacy/bearer profile only -- **AND** SHALL NOT issue a sender-constrained (key-bound) access or refresh credential in response - -#### Scenario: A legacy refresh token exchange is rejected once legacy acceptance is disabled - -- **WHEN** an operator has disabled acceptance of `legacy_0_1` contexts and legacy-profile bearer credentials for a deployment -- **THEN** a subsequent legacy refresh-token exchange attempt SHALL be rejected -- **AND** the client SHALL be directed to perform a fresh authorization interaction to obtain a current-binding credential - -### Requirement: The migration inventory SHALL name a rule for every credential kind in the current credential inventory - -The migration inventory (extending the backward-compatibility and migration coverage previously limited to grants, bearer tokens, `manifest_version`, and clients) SHALL explicitly state, for each credential kind confirmed present in the reference implementation's credential inventory, what a deployment enforcing this Migration Profile does with a pre-existing credential of that kind. At minimum, the inventory SHALL cover: - -- **Owner device-flow tokens** (fixed-duration bearer credentials issued to the owner's own client, e.g. via the device-authorization flow): remain valid bearer credentials under the legacy/bearer profile until their own expiry; they are not owner-grant-bound and are therefore out of scope for the `legacy_0_1` context/grant-enforcement rules above, but SHALL be named explicitly rather than left unaddressed by the migration inventory, since Stage 1 confirms this credential surface exists in the reference implementation. -- **Client access tokens on `continuous` grants with a null expiry**: remain valid under whichever profile (`legacy_0_1` or current) the underlying grant resolves to; a null-expiry client access token is precisely the case the disable/sunset mechanism above exists to bound, since it does not lapse through natural expiry. -- **Non-rotating legacy OAuth refresh tokens**: governed by the refresh-token requirement above. -- **Extension token kinds** (e.g. `mcp_package`-style tokens, and tokens issued against grant packages): treated as extension data under this Migration Profile exactly as Section 15.9 item 6 already treats reference-only grant packages, per-stream `client_claims`, and embedded grant display fields — i.e., the Migration Profile does not newly promote these extension kinds into Core enforcement, but the migration inventory SHALL still name them explicitly (rather than omit them) and state that they inherit the enforcement path (`legacy_0_1` or current) of the grant or package they are bound to. - -This item does not require, and this Migration Profile does not assert, any fact about a specific live deployment's grant inventory, null-expiry-grant counts, or failure mode; the rules above state deployment-independent obligations, cited to the credential inventory's file:line evidence of each kind's current issuance/acceptance/revocation/rotation behavior, not to any deployment's live state. - -**Change class:** repairs an existing interoperability/security hole - -#### Scenario: The migration inventory names a rule for a fixed-duration owner device-flow token - -- **WHEN** the migration inventory is reviewed for an owner token issued through the device-authorization flow -- **THEN** it SHALL state that the token remains a valid legacy-profile bearer credential until its own fixed expiry -- **AND** SHALL state that this credential is out of scope for the `legacy_0_1` context/grant-enforcement rules because it is not grant-bound - -#### Scenario: The migration inventory names a rule for a null-expiry continuous-grant client access token - -- **WHEN** the migration inventory is reviewed for a client access token issued against a `continuous`-mode grant with a null `expires_at` -- **THEN** it SHALL state that the token remains valid under its grant's resolved enforcement path (legacy or current) -- **AND** SHALL state that the disable/sunset mechanism, not natural expiry, is the bounded path to end that acceptance - -#### Scenario: The migration inventory names a rule for extension token kinds - -- **WHEN** the migration inventory is reviewed for an extension token kind such as a package-scoped token bound to a grant package -- **THEN** it SHALL state explicitly that the token is extension data inheriting the enforcement path of the grant or package it is bound to -- **AND** SHALL NOT omit this credential kind from the inventory - -### Requirement: A conformance test suite SHALL assert a fully specified expected result for the required backward-compatibility test - -The backward-compatibility conformance test named as a required negative test (asserting that v0.1 grants remain byte-identical, that a legacy context marks missing fields rather than inventing them, and that a v0.1 credential is not silently upgraded or accepted under a claim of the current sender-constrained security profile) SHALL have a fully specified, unambiguous expected result once this Migration Profile is applied. Conformance testing under this Migration Profile SHALL assert, at minimum: - -- a v0.1 grant's stored bytes are unchanged after being resolved through the `legacy_0_1` path; -- an authorization context resolved for that grant is discriminated as `legacy_0_1` and marks its issuer, exact audience, proof/binding mode, security profile, and source-declaration digest as unavailable, never as a fabricated or inferred value; -- a request presenting that grant's credential is rejected if it is evaluated under the common algorithm's fail-closed rule instead of the `legacy_0_1` path; -- a request presenting that grant's credential is rejected outright once the deployment's legacy-acceptance flag is off or the sunset boundary has passed; -- no response derived from that grant asserts a sender-constrained (key-bound) security profile. - -**Change class:** repairs an existing interoperability/security hole - -#### Scenario: The backward-compatibility test yields one unambiguous pass/fail result - -- **WHEN** the required backward-compatibility conformance test is executed against an implementation of this Migration Profile -- **THEN** each of the assertions above SHALL have exactly one specified expected outcome -- **AND** the test SHALL fail if the implementation upgrades the credential to, or reports it under, a sender-constrained security profile - -### Requirement: PDPP SHALL pin one JSON Schema dialect for common and security-critical schemas - -All PDPP schemas that a conformant implementation validates against — including but not limited to `streams[].schema` (spec-core.md §5 Manifest Format) and any schema a future change later defines for common authorization objects — SHALL declare conformance to a single, named JSON Schema dialect: **JSON Schema draft 2020-12** (`https://json-schema.org/draft/2020-12/schema`). An implementation SHALL NOT validate a declared schema against a different dialect, and a schema document SHALL NOT omit a `$schema` declaration where the dialect would otherwise be ambiguous. - -This requirement resolves an unresolved dialect question and closes the gap where `streams[].schema` names "JSON Schema" with no dialect pinned. - -**Change class:** introduces a genuinely new normative capability - -#### Scenario: A manifest schema declares the pinned dialect - -- **WHEN** a connector manifest declares `streams[].schema` -- **THEN** the schema SHALL be interpreted under JSON Schema draft 2020-12 -- **AND** an authorization server or resource server validating records against that schema SHALL NOT apply a different draft's validation rules - -#### Scenario: Two implementations validate the same schema identically - -- **WHEN** two independent implementations validate the same record against the same declared `streams[].schema` -- **THEN** both SHALL reach the same accept/reject result, because both interpret the schema under the one pinned dialect rather than each implementation's own default - -### Requirement: PDPP SHALL pin one canonical timestamp and duration string profile for canonicalized objects - -Any object that PDPP canonicalizes or digests (including any future grant digest computation, which is out of scope for this change but depends on this pin) SHALL represent every RFC 3339 timestamp field and ISO 8601 duration field using exactly one canonical string profile: - -- **Timestamps** SHALL be represented in RFC 3339 `date-time` form, in the UTC offset, using the literal uppercase `Z` suffix (not a numeric `+00:00` offset), with exactly **zero** fractional-second digits when the underlying instant has no sub-second precision, and otherwise with exactly **three** fractional-second digits (millisecond precision), zero-padded. A timestamp string that a producer cannot express with zero or three fractional digits SHALL be rounded to the nearest millisecond before canonical encoding. -- **Durations** SHALL be represented in ISO 8601 duration form using only the largest applicable calendar designators already illustrated in spec-core.md's `retention.max_duration` examples (e.g. `P6M`, `P1Y`, `P90D`); a duration string SHALL NOT include a time-of-day component (`T` designator) unless the duration is genuinely sub-day, and SHALL NOT use two different representations of the same duration length (e.g. `P90D` and `P3M` are not interchangeable canonical forms — a producer SHALL choose one and represent that duration length consistently across the deployment). - -A field declared as a timestamp or duration in a canonicalized object that does not conform to this profile SHALL be rejected before canonicalization, rather than canonicalized as received. This requirement does not itself define grant-digest computation (RFC 8785 JCS application, hash algorithm, or excluded fields), which remains out of scope for this change; it defines only the string-representation prerequisite that any such future digest computation depends on, because RFC 8785 canonicalizes JSON structure but treats string values as opaque, so semantically identical instants written as `...00Z` and `...00.000Z` would otherwise canonicalize to different bytes and produce different digests. - -This requirement resolves an unresolved timestamp/duration canonicalization question and is a prerequisite the seam-spike gate (see the sequencing requirement below) depends on before its Phase 0 begins. - -**Change class:** introduces a genuinely new normative capability - -#### Scenario: Two producers of the same instant canonicalize to the same bytes - -- **WHEN** two implementations independently produce a timestamp string for the same underlying instant, one naturally rendering it as `2026-08-04T12:00:00Z` and the other as `2026-08-04T12:00:00.000Z` -- **THEN** both SHALL normalize the field to the single pinned profile before canonicalization -- **AND** the resulting canonical bytes for that field SHALL be identical between the two implementations - -#### Scenario: A non-conforming timestamp is rejected before canonicalization - -- **WHEN** a timestamp field intended for a canonicalized object uses a numeric UTC offset (e.g. `+00:00`) or a fractional-second precision other than zero or three digits -- **THEN** the implementation SHALL reject the field rather than pass it through to canonicalization unchanged - -#### Scenario: A duration field uses the pinned calendar-designator form - -- **WHEN** a `retention.max_duration`-shaped duration field is produced for a canonicalized object -- **THEN** it SHALL use the largest applicable calendar designators with no time-of-day component for durations of a day or longer -- **AND** an implementation SHALL NOT emit two different designator forms for what is declared to be the same duration length within one deployment - -### Requirement: The Core/binding decomposition and the closed 0.2 common schemas SHALL remain gated on the repaired seam-spike protocol - -The three-document Core/binding decomposition (a prospective split of normative protocol text into separate Core, OAuth-binding, and owner-profile documents) and the nine 0.2 common schemas (`PDPPSelection`, `PDPPApprovedSelection`, `PDPPRequesterIdentity`, `PDPPConsentEvidence`, `PDPPGrant`, `PDPPGrantState`, `PDPPCredentialFamily`, `PDPPAuthorizationContext`, `PDPPError`, each at 0.2) are NOT settled normative text as of this change. They SHALL NOT be published as normative until the repaired seam-spike protocol defined below has run and passed. This change (PR1) is scoped to the subset of hardening and migration requirements that hold regardless of that decomposition's eventual outcome; the decomposition and the nine schemas are deferred to a subsequent change gated on the spike's result. - -The seam-spike protocol that governs this gate SHALL: - -1. Use a corpus of exactly 13 vectors, where the 13th vector is a v0.1 grant served through a `legacy_0_1` authorization context by a 0.2 resource server. -2. Define "independent," for the purpose of any two-implementation, two-authorization-server, or two-resource-server threshold in the spike, as a separate team or an off-the-shelf product; oracle substitution (evaluating a threshold using the same implementation or team that produced the object under test) SHALL NOT be permitted for the commitment decision. -3. Either give the GNAP adapter leg of the spike binding pass/fail criteria — partial approval returning an unambiguous client-visible result, `single_use` credential exchange behaving exactly-once, and full `PDPPAuthorizationContext`-equivalent resolution — or explicitly and textually declare the GNAP leg non-gating for the decomposition-commitment decision. A mapping-completeness report alone SHALL NOT serve as the GNAP leg's pass criterion if the GNAP leg is declared gating. -4. Be stated as exactly one normative experiment definition, which any other document section referencing the spike SHALL cross-reference rather than restate. -5. Require that the canonical timestamp/duration profile and JSON Schema dialect pin (the two requirements above) are resolved before the spike's first phase begins. - -**Change class:** repairs an existing interoperability/security hole - -#### Scenario: The decomposition is not treated as settled before the spike passes - -- **WHEN** a reader looks for normative status of the three-document Core/binding split or any of the nine 0.2 common schemas -- **THEN** the spec text SHALL state they are not settled normative text -- **AND** SHALL state they remain deferred pending the seam-spike's outcome - -#### Scenario: The spike corpus includes the legacy vector - -- **WHEN** the seam-spike corpus is assembled -- **THEN** it SHALL contain 13 vectors -- **AND** the 13th vector SHALL be a v0.1 grant served through `legacy_0_1` by a 0.2 resource server - -#### Scenario: Independence excludes oracle substitution - -- **WHEN** a two-implementation, two-authorization-server, or two-resource-server threshold in the spike is evaluated for the commitment decision -- **THEN** "independent" SHALL mean a separate team or an off-the-shelf product -- **AND** the same implementation or team serving as both the object under test and its own evaluating oracle SHALL NOT satisfy the threshold - -#### Scenario: The GNAP leg either has real pass criteria or is declared non-gating - -- **WHEN** the GNAP adapter leg of the spike is evaluated -- **THEN** it SHALL be judged against partial-approval-with-unambiguous-result, exactly-once `single_use` behavior, and full authorization-context resolution -- **OR** the spec text SHALL explicitly declare the GNAP leg non-gating for the decomposition-commitment decision - -#### Scenario: One normative experiment definition is cross-referenced, not restated - -- **WHEN** more than one document section describes the seam-spike experiment -- **THEN** exactly one section SHALL state the experiment definition normatively -- **AND** every other section referencing it SHALL cross-reference that one statement rather than restating a possibly-different definition - -#### Scenario: The canonicalization pin precedes the spike's first phase - -- **WHEN** the seam-spike protocol's first phase is scheduled to begin -- **THEN** the pinned JSON Schema dialect and canonical timestamp/duration profile SHALL already be resolved normative text +- **WHEN** the pure GNAP map is evaluated +- **THEN** it SHALL round-trip rights and reject unknown mandatory members, but + SHALL not decide the OAuth/RAR seam or claim GNAP conformance diff --git a/openspec/changes/harden-pdpp-authorization-and-0-1-migration/tasks.md b/openspec/changes/harden-pdpp-authorization-and-0-1-migration/tasks.md index e2f536f57..0020fdbf6 100644 --- a/openspec/changes/harden-pdpp-authorization-and-0-1-migration/tasks.md +++ b/openspec/changes/harden-pdpp-authorization-and-0-1-migration/tasks.md @@ -1,168 +1,152 @@ -## 1. Separated-RS Authorization Context (Introspection) - -- [ ] 1.1 Update `spec-core.md` Section 8 `### Token introspection` (current extension- - fields table) to replace the six-row extension-fields table with the complete - authenticated context: `iss`, `aud`, `authorization_details` (RFC 9396 §9.2/§14.3), - and the `pdpp` supplementary member (`grant_id`, `grant_digest`, `status`, - `consumed_at`, `superseded_by`, consent-evidence reference, security/binding - profile, cache bound). -- [ ] 1.2 Update `spec-core.md` Section 8 `### Grant enforcement` to reference the - complete context object instead of the current six fields, and add the - fail-closed rule for missing/unrecognized mandatory context fields. -- [ ] 1.3 Update `spec-core.md` Section 9 AS conformance and RS conformance items that - reference introspection/`pdpp_token_kind` to point at the complete-context - requirement. -- [ ] 1.4 Update the illustrative `PDPPIntrospectionResponse` TypeScript interface in - Section 12 in lockstep with the new introspection response shape. -- [ ] 1.5 Add the authenticated-introspection-caller requirement (no bearer-only - unauthenticated introspection) to `spec-core.md` Section 8 or Section 10. - -## 2. DPoP Duty Split and private_key_jwt Citation - -- [ ] 2.1 Add a DPoP duty-split note to `spec-core.md` Section 10 `### Token security` - (informative): RS validates `htm`/`htu`/`iat`/`ath`/`nonce`/`jti` per RFC 9449 - §§4-7; introspection supplies `status`+`cnf` only; do not mandate DPoP as MTI in - this pass. -- [ ] 2.2 Add the `private_key_jwt` citation fix (OIDC Core §9 + IANA - client-authentication-method registry, not RFC 7523 alone) with pinned assertion - audience/claims/registration mechanism, near `spec-core.md` Section 10 - Authentication material. -- [ ] 2.3 Resolve `presentation_proof` in any resolver/introspection contract - description touched by this change: drop it or add the non-validation caveat. - -## 3. Authorization-Code One-Time Use - -- [ ] 3.1 Draft the OAuth-binding normative text for authorization-code one-time use in - `spec-core.md` (new subsection; no existing text to supersede); explicitly state - there is no same-DPoP-key idempotency carve-out. -- [ ] 3.2 Add or update the RS/AS error-code table entry for authorization-code reuse - (`invalid_grant` or the binding's named equivalent) alongside the existing - error-code conventions in `spec-core.md` Section 8/9. - -## 4. Refresh-Token Rotation and Replay - -- [ ] 4.1 Draft the refresh-token rotation-on-use, family-lineage, and closed - replay-response enumeration text replacing the current single SHOULD-level - sentence on refresh tokens in `spec-core.md` Section 10. -- [ ] 4.2 Enumerate the closed set of permitted replay responses with precise - per-response effects as binding-declared metadata, not open-ended prose. -- [ ] 4.3 Define the refresh-endpoint retransmission rule (lost-response retry) - distinct from replay-of-superseded-generation, giving the existing lost-response - conformance test class an oracle. - -## 5. Keyless Recovery - -- [ ] 5.1 Draft the new key-recovery subsection in `spec-core.md` Section 10, after - Token security and before Grant integrity: rotation-with-old-key vs. - keyless-recovery vs. public-client non-self-qualification. -- [ ] 5.2 Cross-check the keyless-recovery text against the prior design discussion's - credential-family lifecycle state transitions so the new rule composes with (does - not contradict) the existing state-machine language. -- [ ] 5.3 Add negative-test placeholders/pointers for: authorization-code reuse denial, - refresh-replay enumerated response assertion, refresh-retransmission oracle, and - keyless-recovery rejection absent old-key proof/fresh authorization/suspend+owner- - recovery. - -## 6. Minimum Credential-Security-Profile Floor - -- [ ] 6.1 Add a `minimum_credential_security_profile`-equivalent manifest field to the - Section 5 Manifest fields table, scoped at the manifest/source level (not - per-stream), documenting that absence means no floor and bearer presentation - remains acceptable. -- [ ] 6.2 Add a new normative subsection under Section 10, Security and Privacy - Considerations (after Trust boundary responsibilities, before Data minimization) - stating: (a) the AS MUST refuse to issue below a declared floor, (b) the RS MUST - reject a resolved context below its source's declared floor, (c) consent - disclosure of a reduced-theft-resistance presentation mode is MUST-level when such - a mode is permitted for the source. -- [ ] 6.3 Cross-link the new Section 10 subsection to the existing Sender-constrained - tokens informative note and to the Trust boundary responsibilities table, - clarifying that this requirement upgrades the refusal/rejection obligation to - MUST while leaving concrete binding-specific profile identifiers to the OAuth - binding document (deferred). -- [ ] 6.4 Update the Authorization Server conformance and Resource Server conformance - sections (Section 9) to reference the new floor-refusal and floor-rejection - obligations as conformance items. -- [ ] 6.5 Add negative conformance/test cases: AS rejects below-floor authorization - request; RS rejects below-floor resolved context; consent surface renders the - reduced-theft-resistance disclosure before completing issuance of a permitted - weaker mode. -- [ ] 6.6 Verify no OAuth-binding-specific profile identifier (e.g. a DPoP-only or - bearer-compat label string) is minted in this delta; confirm the mechanism is - described in binding-neutral terms only. - -## 7. PDPP OAuth 0.1 Migration Profile - -- [ ] 7.1 Define the `legacy_0_1` authorization-context discriminator and its - field-availability contract (issuer/audience/proof-mode/binding-security-profile/ - source-digest marked unavailable; client/subject/source/grant-ID-digest/state/ - cache-freshness still enforced) in the new PDPP OAuth 0.1 Migration Profile - document. -- [ ] 7.2 Specify the dual-mode RS algorithm: path selection solely from the resolved - context's discriminated kind, never from token syntax or headers; both paths - enforce the same per-grant constraint narrowing. -- [ ] 7.3 Define the discovery flag (naming convention consistent with existing - `pdpp_*_supported`-style fields) signalling active legacy acceptance and the - associated sunset boundary, and specify absence/false as "off." -- [ ] 7.4 Define the operator-controlled disable/sunset mechanism, including its - interaction with null-expiry continuous grants and non-rotating legacy refresh - tokens, and the immutability of historical grant bytes after disabling. -- [ ] 7.5 Define legacy refresh-token treatment: legacy/bearer-profile-only processing, - no silent reclassification to sender-constrained mode, no silent upgrade, and - rejection once legacy acceptance is disabled. -- [ ] 7.6 Extend the migration inventory to explicitly name rules for owner device-flow - tokens, null-expiry continuous-grant client access tokens, legacy refresh tokens, - and extension token kinds, citing the credential inventory's file:line evidence - per kind. -- [ ] 7.7 Specify the fully determined expected result for the required - backward-compatibility conformance test (byte-identical v0.1 grants; legacy - context marks missing fields; rejection under common-algorithm fail-closed - handling; rejection after legacy acceptance is disabled; no sender-constrained- - profile claim). -- [ ] 7.8 Cross-reference the new Migration Profile from `spec-core.md`'s version- - layering table (Section 7) and out-of-scope table (Section 11), without - introducing v0.2 version labels or `urn:pdpp:...:0.2` identifiers in `spec-core.md` - itself. - -## 8. Canonicalization Pins - -- [ ] 8.1 Add a JSON Schema dialect declaration (`$schema`: draft 2020-12) requirement - to `spec-core.md` Section 5 (Manifest Format), amending the `streams[].schema` - field description to name the pinned dialect. -- [ ] 8.2 Add a canonical timestamp/duration profile subsection to `spec-core.md` - Section 4 (Record Model) near the existing Timestamps subsection, pinning the - Z-suffix / zero-or-three-fractional-digit rule and the calendar-designator - duration rule, and cross-reference it from the retention `max_duration` field and - the grant `issued_at`/`expires_at` fields. -- [ ] 8.3 Add a short normative note that this canonicalization pin is a prerequisite - for any future grant-digest computation, without defining grant-digest computation - itself (that remains deferred/decomposition scope). - -## 9. Seam-Spike Gate - -- [ ] 9.1 Write the repaired seam-spike protocol as a standalone planning document - (sibling to the existing decision-record documents), capturing the 13-vector - corpus, the independence definition, the GNAP pass/fail criteria, and the single - cross-referenced experiment definition — this is a planning/gating artifact, not - spec-`*.md` prose. -- [ ] 9.2 Add the single gating statement (Core/binding decomposition and nine 0.2 - common schemas are not settled, deferred pending the spike) to this proposal, and - consider a short cross-reference note in `spec-core.md`'s introduction alongside - its existing companion-document references, without inlining decomposition - content itself. -- [ ] 9.3 Reconcile the prior design discussion's three separate experiment-definition - statements into the one seam-spike document produced above; update the other two - locations, as later non-PR1 editorial work, to cross-reference it. - -## 10. Labeling and Validation - -- [ ] 10.1 Confirm every requirement in `specs/pdpp-authorization-hardening/spec.md` - carries exactly one of the three approved change-class labels - (`formalizes an existing v0.1 semantic requirement`, - `repairs an existing interoperability/security hole`, - `introduces a genuinely new normative capability`). -- [ ] 10.2 Confirm every requirement has at least one `#### Scenario:` block. -- [ ] 10.3 Confirm no requirement asserts a fact about any live deployment and no - requirement mints a `"0.2"` version label or `urn:pdpp:...:0.2` identifier. -- [ ] 10.4 Run `openspec validate harden-pdpp-authorization-and-0-1-migration --strict`. -- [ ] 10.5 Run `openspec validate --all --strict`. +## 1. Context and Source contract + +- [x] 1.1 Implement the Source-defined `ApprovedAuthorization` parser with + `source_id`, `access_mode`, stream names, unique nonempty `instance_ids`, + unique nonempty `fields`, optional frozen-field bounds, and optional + canonical resources. +- [x] 1.2 Keep `source.kind` outside authorization equality and reject every + metadata mismatch before projection, plus every stable invalid-input + code listed in the execution document. +- [x] 1.3 Add instance and temporal-field rows to Cases 1, 3, and 4. + +## 2. OAuth/RAR and introspection + +- [x] 2.1 Run the real authorization-code and PKCE path with partial approval + and assert the granted `authorization_details`, including approved + purpose, retention, and selection provenance. + Assert that Source-neutral `source.authorization_details_invalid` maps + to RFC 9396 `invalid_authorization_details` at the OAuth boundary. +- [x] 2.2 Use authenticated RFC 7662 HTTP introspection with operator-provided + or per-process generated confidential-RS credentials. Keep fixed + credentials in test helpers only, and keep long-term registration out of + PR89. +- [x] 2.3 Test expiration, issuer, audience, identity, source, context-kind, + approved-rights, instance, and field mismatches before route handling. +- [x] 2.4 Assert response-only RS enforcement with no in-process fallback or + second AS lookup. Keep DPoP text conditional to the RFC 9449 AS versus RS + duty split without adding nonce or `jti` policy. + +## 3. One-use and refresh lifecycle + +- [x] 3.1 Test authorization-code one-use and same-valid-PKCE concurrent races + against PostgreSQL. Report post-reuse token revocation separately at RFC + 6749 SHOULD strength unless exact linkage is proven. +- [x] 3.2 Test `single_use` issuance races against PostgreSQL. +- [x] 3.3 Implement and test refresh rotation, family state, + superseded-generation reuse, family revocation, lost-response retry, + `invalid_grant`, and fresh authorization per RFC 9700. Record these + results separately from the seven seam decisions. +- [x] 3.4 Persist refresh-family linkage and a bounded token-specific expiry on + every initial and refresh-derived grant or package bearer. On replay, + atomically revoke the family and every linked bearer in SQLite and + PostgreSQL, and make introspection reject all of them. +- [x] 3.5 Issue refresh tokens only for continuous grants and packages whose + children are all continuous. Derive `expires_in` from persisted access + expiry and omit `expires_in` or RFC 7662 `exp` when absent. +- [x] 3.6 Add attacker-first grant and package replay tests, backend parity, + race coverage, and fault-injection rollback coverage. +- [x] 3.7 Fail closed during SQLite and PostgreSQL migration when a live legacy + refresh family has no persisted bearer linkage. Revoke the family and + its bound bearers, require fresh authorization, and do not infer a + backfill. + +## 4. Breaking persisted-state boundary + +- [x] 4.1 Load pre-v0.1 authorization-state bytes through the current + persisted-grant reader and reject them with + `authorization_state.unsupported_legacy_shape`. +- [x] 4.2 Assert rejection occurs before introspection and route handling and + requires fresh consent. +- [x] 4.3 Assert the reader does not reconstruct `instance_ids`, issuer, + audience, source identity, or any other missing fact from current + configuration. +- [x] 4.4 Do not add an acceptance flag, compatibility adapter, alternate + context kind, persisted-state inventory, discovery, or sunset + requirements. + +## 5. GNAP map + +- [x] 5.1 Implement only the pure rights round-trip and narrowed approval map. +- [x] 5.2 Reject unknown mandatory members and mark unimplemented controls + `not demonstrated`. GNAP remains non-gating. + +## 6. Receipt and CI + +- [x] 6.1 Generate the exact receipt JSON schema from the seven seam cases and + the durable-handoff case. +- [x] 6.2 Compute a relevant-file tree digest that excludes the receipt and + generated artifacts. Do not use a self-referential commit or source + revision field. +- [x] 6.3 Run the receipt checker in CI and fail on missing cases, stale + digests, duplicated rights, fallback markers, or invented passes. + +## 7. Deferred questions + +- [x] 7.1 Record keyless recovery and the security-profile floor as explicit + deferred questions with their nonblocking reason and future unlock. +- [x] 7.2 Remove timestamp and duration canonicalization requirements and all + seam prerequisite references. Defer value canonicalization until a digest + algorithm and temporal/duration semantics are designed together. + +## 8. Ownership and validation + +- [x] 8.1 Record the five-change ownership and merge-order matrix: Source + contract; discovery contract; Source implementation; discovery trust + implementation and accepted-revision bridge; OAuth/RAR hardening. +- [x] 8.2 Make PR89 consume the Source-defined resolved contract without a + second grant schema; discovery consumes both. +- [x] 8.3 Run `openspec validate harden-pdpp-authorization-and-0-1-migration + --strict`. +- [ ] 8.4 Run `openspec validate --all --strict`. +- [ ] 8.5 Run the existing targeted tests, the PostgreSQL seam target, the + receipt checker, `git diff --check`, and stale sweeps. +- [x] 8.6 Write the required Waspflow checkpoint to + `/home/tnunamak/.tmp/pdpp-spec-program-0811.xkarws/research/pr89-wasp-checkpoint.md`. + +## 9. Durable post-approval handoff correction + +- [x] 9.1 Replace the process-local consent exchange map with hashed, + database-backed handoff rows for SQLite and PostgreSQL. Resolve the + bearer through the existing token authority rather than persisting a + second plaintext copy. +- [x] 9.2 Make an already-committed approval resumable so failure before HTML + handoff delivery can create a fresh bounded exchange code without a + second grant or token. +- [x] 9.3 Make redemption atomic and response-loss idempotent: concurrent or + repeated redemption of one unexpired proof-bound code returns the same + grant and token and records one first-redemption transition; keep the + proofless HTML code single-use and bearer-free on replay. +- [x] 9.4 Add SQLite restart and concurrency tests plus a live PostgreSQL + concurrency test. Preserve the JSON and OAuth authorization-code paths. +- [x] 9.5 Run focused tests, TypeScript, strict OpenSpec validation, query + registry validation, `git diff --check`, and stale sweeps for the removed + process-local map. +- [x] 9.6 Keep the contract and focused evidence explicit about proofless HTML + single-use versus proof-bound out-of-band response-loss replay. HTML + SHALL never embed a proof-bound code; missing or wrong proof SHALL remain + a bounded, bearer-free failure. + +## 10. Token response cache controls + +- [x] 10.1 Set `Cache-Control: no-store` and `Pragma: no-cache` through one + shared response helper on every successful authorization-code, + refresh-token, and device-code token response, including package + variants. +- [x] 10.2 Add a route-level response matrix for grant and package envelopes + and assert token errors and unsupported grants do not serialize a token + success envelope. +- [x] 10.3 Update the Core token-security contract, Case 2 execution oracle, + and this change's requirement/design records. Token lifetime and + grant-family containment remain owned by lifecycle tasks 3.3-3.7. + +## 11. Terminal decision arbitration + +- [x] 11.1 Make ordinary and owner-device denial use a pending-state + compare-and-set and commit the denial event in the same SQLite or + PostgreSQL transaction. +- [x] 11.2 Return `approval_conflict` when approval or denial loses the + terminal decision. Never return denial success or emit denial evidence + after approval wins. +- [x] 11.3 Prove both orderings, transaction rollback, mixed contention, and + persisted terminal state. Bind ordinary, batch, owner-device, HTTP 409, + SQLite, and live PostgreSQL evidence into Case 8. diff --git a/packages/reference-contract/src/common/index.ts b/packages/reference-contract/src/common/index.ts index 1415dab2b..8c34235f6 100644 --- a/packages/reference-contract/src/common/index.ts +++ b/packages/reference-contract/src/common/index.ts @@ -175,6 +175,7 @@ export const OAuthErrorSchema: JsonSchema = { properties: { error: { type: "string" }, error_description: { type: "string" }, + fresh_authorization_required: { type: "boolean" }, request_id: { type: "string" }, }, required: ["error", "request_id"], diff --git a/packages/reference-contract/src/docs/generate.ts b/packages/reference-contract/src/docs/generate.ts index 8e6a1a03c..deb967b1a 100644 --- a/packages/reference-contract/src/docs/generate.ts +++ b/packages/reference-contract/src/docs/generate.ts @@ -25,16 +25,26 @@ function methodBadge(method: string): string { return `**${method}**`; } -function propertyLines(heading: string, properties: Record<string, JsonSchema>): string[] { +function propertyLines(heading: string, properties: Record<string, JsonSchema>, separator: string): string[] { const lines: string[] = [heading, ""]; for (const [name, schema] of Object.entries(properties)) { - lines.push(`- \`${name}\` — ${describeSchema(schema)}`); + lines.push(`- \`${name}\` ${separator} ${describeSchema(schema)}`); } lines.push(""); return lines; } -function requestBodyLines(body: NonNullable<RouteManifest["request"]>["body"]): string[] { +function requiredAlternativesLines(label: string, variants: readonly JsonSchema[] | undefined): string[] { + const requiredSets = (variants || []) + .map((variant) => variant.required || []) + .filter((required) => required.length > 0); + if (requiredSets.length === 0) { + return []; + } + return ["", label, ...requiredSets.map((required) => `- ${required.map((name) => `\`${name}\``).join(" + ")}`)]; +} + +function requestBodyLines(body: NonNullable<RouteManifest["request"]>["body"], separator: string): string[] { if (!body) { return []; } @@ -42,36 +52,58 @@ function requestBodyLines(body: NonNullable<RouteManifest["request"]>["body"]): if (body.schema?.properties) { for (const [name, schema] of Object.entries(body.schema.properties)) { const required = (body.schema.required || []).includes(name); - lines.push(`- \`${name}\`${required ? " (required)" : ""} — ${describeSchema(schema)}`); + lines.push(`- \`${name}\`${required ? " (required)" : ""} ${separator} ${describeSchema(schema)}`); } + lines.push(...requiredAlternativesLines("Required alternatives:", body.schema.oneOf)); + lines.push(...requiredAlternativesLines("At least one required:", body.schema.anyOf)); + } else if (Array.isArray(body.schema?.oneOf)) { + body.schema.oneOf.forEach((variant, index) => { + if (!variant.properties) { + return; + } + const properties: Record<string, JsonSchema> = variant.properties; + lines.push("", `Alternative ${index + 1}:`); + for (const [name, schema] of Object.entries(properties)) { + const required = (variant.required || []).includes(name); + lines.push(`- \`${name}\`${required ? " (required)" : ""} ${separator} ${describeSchema(schema)}`); + } + lines.push(...requiredAlternativesLines(" Required alternatives:", variant.oneOf)); + lines.push(...requiredAlternativesLines(" At least one required:", variant.anyOf)); + }); } lines.push(""); return lines; } -function manifestDetailLines(m: RouteManifest): string[] { +function manifestDetailLines(m: RouteManifest, separator: string): string[] { const lines: string[] = [`## ${m.id}`, "", `\`${m.method} ${m.path}\``, ""]; if (m.summary) { lines.push(m.summary, ""); } const q = m.request?.query?.properties; if (q) { - lines.push(...propertyLines("### Query parameters", q)); + lines.push(...propertyLines("### Query parameters", q, separator)); } const p = m.request?.params?.properties; if (p) { - lines.push(...propertyLines("### Path parameters", p)); + lines.push(...propertyLines("### Path parameters", p, separator)); } - lines.push(...requestBodyLines(m.request?.body)); + lines.push(...requestBodyLines(m.request?.body, separator)); lines.push("### Responses", ""); for (const [code, spec] of Object.entries(m.responses || {})) { - lines.push(`- \`${code}\` — ${spec.description || (spec.schema ? "JSON body" : "")}`); + lines.push(`- \`${code}\` ${separator} ${spec.description || (spec.schema ? "JSON body" : "")}`); } lines.push(""); return lines; } -function manifestsToRouteMarkdown(manifests: readonly RouteManifest[], title: string, lead: string): string { +function manifestsToRouteMarkdown( + manifests: readonly RouteManifest[], + title: string, + lead: string, + options: { separator?: string } = {} +): string { + const separator = options.separator || "\u2014"; const lines: string[] = [`# ${title}`, ""]; if (lead) { lines.push(lead, ""); @@ -84,7 +116,7 @@ function manifestsToRouteMarkdown(manifests: readonly RouteManifest[], title: st } lines.push(""); for (const m of manifests) { - lines.push(...manifestDetailLines(m)); + lines.push(...manifestDetailLines(m, separator)); } return lines.join("\n"); } @@ -120,7 +152,7 @@ function queryCookbook(): string { return [ "# PDPP query cookbook", "", - "All examples below target the public record-query surface at `/v1/streams/...`. Tokens are Bearer access tokens bound to a PDPP grant. Core spec §8 (Resource Server Interface) is authoritative for query syntax — the canonical `filter[<field>]` / `filter[<field>][op]` shapes, declaration-driven `query.range_filters` and `query.expand`, and the `limit_clamped` warning. This cookbook shows the smallest correct call for each shape; where it is terser than §8, §8 governs.", + "All examples below target the public record-query surface at `/v1/streams/...`. Tokens are Bearer access tokens bound to a PDPP grant. Core spec §8 (Resource Server Interface) is authoritative for query syntax - the canonical `filter[<field>]` / `filter[<field>][op]` shapes, declaration-driven `query.range_filters` and `query.expand`, and the `limit_clamped` warning. This cookbook shows the smallest correct call for each shape; where it is terser than §8, §8 governs.", "", "## Discovery (one shot)", "", @@ -202,9 +234,9 @@ function queryCookbook(): string { "", "## Logical cursor pagination", "", - "Records are sorted by `(cursor_field, primary_key)`. Null cursor values sort after present values. Cursors are opaque — clients must not parse or construct them. Cursors are direction-bound: follow a page cursor with the same `order` value that produced it. To change direction, restart pagination without a cursor; the reference rejects order-mismatched cursors as `invalid_cursor`.", + "Records are sorted by `(cursor_field, primary_key)`. Null cursor values sort after present values. Cursors are opaque - clients must not parse or construct them. Cursors are direction-bound: follow a page cursor with the same `order` value that produced it. To change direction, restart pagination without a cursor; the reference rejects order-mismatched cursors as `invalid_cursor`.", "", - '`limit` defaults to 25 and is capped at 100. A request for more than 100 is clamped to 100 and returns a non-fatal `meta.warnings[]` entry with `code: "limit_clamped"`, not an error — page forward with the returned cursor rather than expecting a larger page.', + '`limit` defaults to 25 and is capped at 100. A request for more than 100 is clamped to 100 and returns a non-fatal `meta.warnings[]` entry with `code: "limit_clamped"`, not an error - page forward with the returned cursor rather than expecting a larger page.', "", "```http", "GET /v1/streams/top_artists/records?order=asc&limit=50", @@ -232,7 +264,7 @@ function queryCookbook(): string { "", "## Blob fetch", "", - "Records that include attachment-like bytes carry a `data.blob_ref` object. The reference RS decorates that object with a `fetch_url` (e.g., `/v1/blobs/<blob_id>`) which is the only supported byte-fetch path. There is no `/v1/attachments/<id>/content` (or similar) endpoint — discover bytes from the record's `blob_ref.fetch_url` rather than constructing attachment-specific content URLs.", + "Records that include attachment-like bytes carry a `data.blob_ref` object. The reference RS decorates that object with a `fetch_url` (e.g., `/v1/blobs/<blob_id>`) which is the only supported byte-fetch path. There is no `/v1/attachments/<id>/content` (or similar) endpoint - discover bytes from the record's `blob_ref.fetch_url` rather than constructing attachment-specific content URLs.", "", "```http", "GET /v1/blobs/<blob_id>", @@ -245,8 +277,10 @@ function queryCookbook(): string { "", "1. Register a client: `POST /oauth/register` (DCR initial access token required).", "2. Start a grant request: `POST /oauth/par` with `authorization_details[0].type = https://pdpp.dev/data-access`.", - "3. Approve via the hosted consent page or `POST /consent/approve` with `request_uri` + subject id.", - "4. In the current thin reference flow, `POST /consent/approve` returns `{ grant_id, token, grant }` directly; there is no follow-on `/oauth/token` exchange for third-party client connect yet.", + "3. Review the request with `POST /consent/review` and inspect the exact `approval_review` artifact and `approval_review_revision`.", + "4. Approve with `POST /consent/approve` using `request_uri` and `approval_review_revision`. Do not submit stream or field choices again.", + "5. For a finalized batch review, also send `confirm_reviewed_decision` with the approval revision.", + "6. In the current thin reference flow, `POST /consent/approve` returns `{ grant_id, token, grant }` directly; there is no follow-on `/oauth/token` exchange for third-party client connect yet.", "", "## Owner device flow", "", @@ -256,16 +290,16 @@ function queryCookbook(): string { "", "## Error codes (spec §8)", "", - "- `400 invalid_request` — malformed query shape (unknown param, bad filter shape, nested path).", - "- `400 unknown_field` — `fields=` references a field outside the stream schema.", - "- `400 invalid_expand` — expansion requests an undeclared or non-`has_many` relation.", - "- `400 invalid_cursor` — cursor token malformed.", - "- `403 field_not_granted` — filter targets a field outside the grant projection.", - "- `403 grant_stream_not_allowed` — stream not in grant.", - "- `403 insufficient_scope` — expansion requests a stream not in the grant.", - "- `404 not_found` — stream or record not found.", - "- `404 blob_not_found` — `blob_id` is unknown or stale.", - "- `410 cursor_expired` — `changes_since` cursor too old; full re-sync required.", + "- `400 invalid_request` - malformed query shape (unknown param, bad filter shape, nested path).", + "- `400 unknown_field` - `fields=` references a field outside the stream schema.", + "- `400 invalid_expand` - expansion requests an undeclared or non-`has_many` relation.", + "- `400 invalid_cursor` - cursor token malformed.", + "- `403 field_not_granted` - filter targets a field outside the grant projection.", + "- `403 grant_stream_not_allowed` - stream not in grant.", + "- `403 insufficient_scope` - expansion requests a stream not in the grant.", + "- `404 not_found` - stream or record not found.", + "- `404 blob_not_found` - `blob_id` is unknown or stale.", + "- `410 cursor_expired` - `changes_since` cursor too old; full re-sync required.", "", ].join("\n"); } @@ -285,7 +319,8 @@ export function generateDocs(): { routes: string; referenceRoutes: string; cookb routes: manifestsToRouteMarkdown( publicManifests, "PDPP reference-implementation public API", - "Generated from `packages/reference-contract/src/public/`. Do not edit by hand." + "Generated from `packages/reference-contract/src/public/`. Do not edit by hand.", + { separator: "-" } ), }; } diff --git a/packages/reference-contract/src/public/index.ts b/packages/reference-contract/src/public/index.ts index a97f37295..d49b9f58e 100644 --- a/packages/reference-contract/src/public/index.ts +++ b/packages/reference-contract/src/public/index.ts @@ -61,6 +61,127 @@ const NonEmptyStringSchema = { type: "string", }; +const HTTPS_NO_FRAGMENT_OR_USERINFO_URI_RE = + /^(?!.*[\p{Cc}\s\\#])(?!.*%(?![0-9A-Fa-f]{2}))[Hh][Tt][Tt][Pp][Ss]:\/\/(?![^/?#]*@)(?:\[[0-9A-Fa-f:.]+\](?::\d+)?|[^/?#\s\\@:%]+(?::\d+)?)(?:[/?][^\s\\#]*)?$/u; + +const SourceDeclarationUriSchema = { + format: "uri", + pattern: HTTPS_NO_FRAGMENT_OR_USERINFO_URI_RE.source, + type: "string", +}; + +const RFC_9728_RESOURCE_IDENTIFIER_RE = /^(https):\/\/([^/?#\\]+)([/?][^#\\]*)?$/i; +const FORBIDDEN_URI_CODE_POINT_RE = /[\p{Cc}\s\\#]/u; +const INVALID_PERCENT_ENCODING_RE = /%(?![0-9A-Fa-f]{2})/u; +const IPV6_AUTHORITY_RE = /^\[[0-9A-Fa-f:.]+\](?::\d+)?$/u; +const HOST_AND_PORT_RE = /^[^:]+:\d+$/u; + +function containsNonAsciiCodePoint(value: string): boolean { + for (const character of value) { + if ((character.codePointAt(0) ?? 0) > 127) { + return true; + } + } + return false; +} + +function hasStrictHttpsAuthority(authority: string): boolean { + if (authority.includes("%") || authority.includes("@")) { + return false; + } + + if (authority.startsWith("[")) { + return IPV6_AUTHORITY_RE.test(authority); + } + + return !authority.includes(":") || HOST_AND_PORT_RE.test(authority); +} + +function isStrictHttpsUri(value: string): boolean { + const components = RFC_9728_RESOURCE_IDENTIFIER_RE.exec(value); + if ( + components === null || + FORBIDDEN_URI_CODE_POINT_RE.test(value) || + INVALID_PERCENT_ENCODING_RE.test(value) || + containsNonAsciiCodePoint(value) || + !hasStrictHttpsAuthority(components[2] ?? "") + ) { + return false; + } + + try { + const parsed = new URL(value); + return ( + parsed.protocol === "https:" && + parsed.hostname !== "" && + parsed.hash === "" && + parsed.username === "" && + parsed.password === "" + ); + } catch { + return false; + } +} + +/** + * Apply RFC 9728 Section 3's well-known URI transformation without + * normalizing the resource identifier that must later compare exactly. + */ +export function deriveProtectedResourceMetadataUrl(resourceIdentifier: string): string { + const components = RFC_9728_RESOURCE_IDENTIFIER_RE.exec(resourceIdentifier); + if (components === null || !isStrictHttpsUri(resourceIdentifier)) { + throw new TypeError("RFC 9728 resource identifiers must be HTTPS URLs without fragments or user information"); + } + + const authority = `${components[1]}://${components[2]}`; + const capturedPathOrQuery = components[3] ?? ""; + let pathOrQuery = capturedPathOrQuery; + if (capturedPathOrQuery === "/") { + pathOrQuery = ""; + } else if (capturedPathOrQuery.startsWith("/?")) { + pathOrQuery = capturedPathOrQuery.slice(1); + } + return `${authority}/.well-known/oauth-protected-resource${pathOrQuery}`; +} + +/** RFC 9728 Section 3.3 requires string identity, not URL equivalence. */ +export function hasExactProtectedResourceIdentity(requestedResource: string, returnedResource: string): boolean { + return returnedResource === requestedResource; +} + +export type ProviderNativeDiscoveryValidationResult = + | { ok: true; sourceDeclarationUri: string } + | { + ok: false; + reason: "invalid_resource" | "invalid_source_declaration_uri" | "resource_mismatch"; + }; + +/** Apply the PDPP provider-native profile to a generic RFC 9728 document. */ +export function validateProviderNativeDiscoveryMetadata( + requestedResource: string, + metadata: Readonly<Record<string, unknown>> +): ProviderNativeDiscoveryValidationResult { + if (!isStrictHttpsUri(requestedResource)) { + return { ok: false, reason: "invalid_resource" }; + } + if (typeof metadata.resource !== "string" || !isStrictHttpsUri(metadata.resource)) { + return { ok: false, reason: "invalid_resource" }; + } + if (!hasExactProtectedResourceIdentity(requestedResource, metadata.resource)) { + return { ok: false, reason: "resource_mismatch" }; + } + + const sourceDeclarationUri = metadata.pdpp_source_declaration_uri; + if (typeof sourceDeclarationUri !== "string") { + return { ok: false, reason: "invalid_source_declaration_uri" }; + } + if (!isStrictHttpsUri(sourceDeclarationUri)) { + return { ok: false, reason: "invalid_source_declaration_uri" }; + } + + return { ok: true, sourceDeclarationUri }; +} + export const BATCH_CONSENT_STAGED_ENTRY_SOFT_CAP = 8; export const BATCH_CONSENT_STAGED_ENTRY_WARNING_THRESHOLD = 6; @@ -207,7 +328,7 @@ const ListRecordsQuerySchema = { fields: { type: "string" }, filter: { description: - "Per-field filter map. Exact: `filter[field]=value`. Range: `filter[field][op]=value` where `op` is one of the declared `field_capabilities.range_filter.operators` from `GET /v1/schema`.", + "Owner-token current-capability filter map only. Client-token v0.1 reads reject exact `filter[field]=value` and range `filter[field][op]=value` before consulting current source metadata.", type: "object", }, limit: { maximum: 100, minimum: 1, type: "integer" }, @@ -666,6 +787,7 @@ const ProtectedResourceMetadataSchema = { pdpp_owner_agent_onboarding: ProtectedResourceOwnerAgentOnboardingSchema, pdpp_provider_connect_version: NonEmptyStringSchema, pdpp_self_export_supported: { type: "boolean" }, + pdpp_source_declaration_uri: SourceDeclarationUriSchema, pdpp_token_kinds_supported: { items: { enum: ["owner", "client"], type: "string" }, minItems: 1, @@ -917,6 +1039,360 @@ const GrantApprovalResponseSchema = { type: "object", }; +const BatchGrantApprovalResponseSchema = { + additionalProperties: false, + properties: { + grant: { + additionalProperties: false, + properties: { + child_grants: { + items: { + additionalProperties: false, + properties: { + grant_id: NonEmptyStringSchema, + source: { + additionalProperties: false, + properties: { + connection_id: NonEmptyStringSchema, + id: NonEmptyStringSchema, + kind: NonEmptyStringSchema, + }, + required: ["id"], + type: "object", + }, + }, + required: ["grant_id", "source"], + type: "object", + }, + minItems: 1, + type: "array", + }, + grant_id: NonEmptyStringSchema, + package: { const: true }, + package_id: NonEmptyStringSchema, + }, + required: ["child_grants", "grant_id", "package", "package_id"], + type: "object", + }, + package_id: NonEmptyStringSchema, + token: NonEmptyStringSchema, + }, + required: ["grant", "package_id", "token"], + type: "object", +}; + +const BatchSourceNarrowingSchema = { + additionalProperties: false, + properties: { + fields: { + additionalProperties: { + items: NonEmptyStringSchema, + type: "array", + }, + type: "object", + }, + since: { + additionalProperties: NonEmptyStringSchema, + type: "object", + }, + streams: { + items: NonEmptyStringSchema, + type: "array", + }, + }, + type: "object", +}; + +const BatchApprovalReviewRequestSchema = { + additionalProperties: false, + anyOf: [ + { required: ["approved_source_indexes"] }, + { required: ["confirm_approve_all"] }, + { required: ["source_narrowing"] }, + ], + oneOf: [{ required: ["request_uri"] }, { required: ["approval_id"] }], + properties: { + approval_id: NonEmptyStringSchema, + approved_source_indexes: { + oneOf: [ + { minimum: 0, type: "integer" }, + { pattern: "^[0-9]+$", type: "string" }, + { + items: { + oneOf: [ + { minimum: 0, type: "integer" }, + { pattern: "^[0-9]+$", type: "string" }, + ], + }, + type: "array", + }, + ], + }, + confirm_approve_all: { + oneOf: [{ type: "boolean" }, { enum: ["true", "1", "on"], type: "string" }], + }, + request_uri: NonEmptyStringSchema, + source_narrowing: { + additionalProperties: BatchSourceNarrowingSchema, + propertyNames: { pattern: "^(0|[1-9][0-9]*)$" }, + type: "object", + }, + subject_id: NonEmptyStringSchema, + }, + type: "object", +}; + +const SingleApprovalReviewRequestSchema = { + additionalProperties: false, + oneOf: [{ required: ["request_uri"] }, { required: ["approval_id"] }], + properties: { + approval_id: NonEmptyStringSchema, + ai_training_consented: { + oneOf: [{ type: "boolean" }, { enum: ["true", "false", "1", "0", "on", "off"], type: "string" }], + }, + request_uri: NonEmptyStringSchema, + subject_id: NonEmptyStringSchema, + }, + type: "object", +}; + +const ReviewClientDisplaySchema = { + additionalProperties: false, + properties: { + logo_uri: { oneOf: [UriSchema, { type: "null" }] }, + name: { oneOf: [NonEmptyStringSchema, { type: "null" }] }, + policy_uri: { oneOf: [UriSchema, { type: "null" }] }, + tos_uri: { oneOf: [UriSchema, { type: "null" }] }, + uri: { oneOf: [UriSchema, { type: "null" }] }, + }, + type: "object", +}; + +const ReviewClientSchema = { + additionalProperties: false, + properties: { + client_display: { + oneOf: [ReviewClientDisplaySchema, { type: "null" }], + }, + client_id: NonEmptyStringSchema, + registration_mode: { + enum: ["dynamic", "client_id_metadata_document", "pre_registered_public"], + type: "string", + }, + }, + required: ["client_id", "registration_mode"], + type: "object", +}; + +const ReviewRetentionSchema = { + oneOf: [GrantSchema.properties.retention, { type: "null" }], +}; + +const ReviewSourceDeclarationSchema = { + oneOf: [ + { + additionalProperties: false, + properties: { + digest: NonEmptyStringSchema, + version: NonEmptyStringSchema, + }, + required: ["digest", "version"], + type: "object", + }, + { + additionalProperties: false, + properties: { + digest: NonEmptyStringSchema, + publisher_attribution: { + additionalProperties: false, + properties: { + id: NonEmptyStringSchema, + status: { const: "unverified" }, + }, + required: ["id", "status"], + type: "object", + }, + resource_authority: { + additionalProperties: false, + properties: { status: { const: "local_operator_provisioned" } }, + required: ["status"], + type: "object", + }, + version: NonEmptyStringSchema, + }, + required: ["digest", "publisher_attribution", "resource_authority", "version"], + type: "object", + }, + { + additionalProperties: false, + properties: { + accepted_revision_reference: NonEmptyStringSchema, + digest: NonEmptyStringSchema, + publisher_attribution: { + additionalProperties: false, + properties: { + id: NonEmptyStringSchema, + status: { const: "unverified" }, + }, + required: ["id", "status"], + type: "object", + }, + resource_authority: { + additionalProperties: false, + properties: { + authority_binding: NonEmptyStringSchema, + status: { const: "verified" }, + }, + required: ["authority_binding", "status"], + type: "object", + }, + version: NonEmptyStringSchema, + }, + required: ["accepted_revision_reference", "digest", "publisher_attribution", "resource_authority", "version"], + type: "object", + }, + ], +}; + +const ReviewClientClaimsSchema = { + oneOf: [ + { + additionalProperties: false, + properties: { + commitments: { + items: NonEmptyStringSchema, + minItems: 1, + type: "array", + uniqueItems: true, + }, + }, + required: ["commitments"], + type: "object", + }, + { type: "null" }, + ], +}; + +const SingleApprovalReviewArtifactSchema = { + additionalProperties: false, + properties: { + access_mode: { enum: ["continuous", "single_use"], type: "string" }, + ai_training_consented: { type: ["boolean", "null"] }, + client: ReviewClientSchema, + client_claims: ReviewClientClaimsSchema, + expires_at: { type: ["string", "null"] }, + purpose_code: NonEmptyStringSchema, + purpose_description: { type: ["string", "null"] }, + resolved_streams: GrantSchema.properties.streams, + retention: ReviewRetentionSchema, + selection_preset: { type: ["string", "null"] }, + source: GrantSchema.properties.source, + source_declaration: ReviewSourceDeclarationSchema, + subject: { + additionalProperties: false, + properties: { id: NonEmptyStringSchema }, + required: ["id"], + type: "object", + }, + version: { const: "reference.approval-review.v1" }, + }, + required: [ + "access_mode", + "ai_training_consented", + "client", + "client_claims", + "expires_at", + "purpose_code", + "purpose_description", + "resolved_streams", + "retention", + "selection_preset", + "source", + "source_declaration", + "subject", + "version", + ], + type: "object", +}; + +const BatchApprovalReviewArtifactSchema = { + additionalProperties: false, + properties: { + access_mode: { enum: ["continuous", "single_use"], type: ["string", "null"] }, + approved_source_indexes: { items: { minimum: 0, type: "integer" }, type: "array" }, + client: ReviewClientSchema, + expires_at: { type: ["string", "null"] }, + parent_package_id: { type: ["string", "null"] }, + source_narrowing: { + additionalProperties: BatchSourceNarrowingSchema, + propertyNames: { pattern: "^(0|[1-9][0-9]*)$" }, + type: "object", + }, + sources: { + items: { + additionalProperties: false, + properties: { + access_mode: { enum: ["continuous", "single_use"], type: "string" }, + client_claims: ReviewClientClaimsSchema, + index: { minimum: 0, type: "integer" }, + purpose_code: NonEmptyStringSchema, + purpose_description: { type: ["string", "null"] }, + resolved_streams: GrantSchema.properties.streams, + retention: ReviewRetentionSchema, + selection_preset: { type: ["string", "null"] }, + source: GrantSchema.properties.source, + source_declaration: ReviewSourceDeclarationSchema, + }, + required: [ + "access_mode", + "client_claims", + "index", + "purpose_code", + "purpose_description", + "resolved_streams", + "retention", + "selection_preset", + "source", + "source_declaration", + ], + type: "object", + }, + type: "array", + }, + subject: { + additionalProperties: false, + properties: { id: NonEmptyStringSchema }, + required: ["id"], + type: "object", + }, + version: { const: "reference.batch-approval-review.v1" }, + }, + required: [ + "access_mode", + "approved_source_indexes", + "client", + "expires_at", + "parent_package_id", + "source_narrowing", + "sources", + "subject", + "version", + ], + type: "object", +}; + +const BatchApprovalReviewResponseSchema = { + additionalProperties: false, + properties: { + approval_review: { oneOf: [SingleApprovalReviewArtifactSchema, BatchApprovalReviewArtifactSchema] }, + approval_review_revision: NonEmptyStringSchema, + batch: { type: "boolean" }, + request_uri: NonEmptyStringSchema, + }, + required: ["approval_review", "approval_review_revision", "batch", "request_uri"], + type: "object", +}; + const RevokeGrantResponseSchema = { additionalProperties: false, properties: { @@ -926,6 +1402,32 @@ const RevokeGrantResponseSchema = { type: "object", }; +const ApproveConsentRequestSchema = { + oneOf: [ + { + additionalProperties: false, + properties: { + approval_review_revision: NonEmptyStringSchema, + request_uri: NonEmptyStringSchema, + }, + required: ["approval_review_revision", "request_uri"], + type: "object", + }, + { + additionalProperties: false, + properties: { + approval_review_revision: NonEmptyStringSchema, + confirm_reviewed_decision: { + oneOf: [{ type: "boolean" }, { enum: ["true", "1", "on"], type: "string" }], + }, + request_uri: NonEmptyStringSchema, + }, + required: ["approval_review_revision", "confirm_reviewed_decision", "request_uri"], + type: "object", + }, + ], +}; + const RecordSchema = { additionalProperties: true, properties: { @@ -1191,15 +1693,7 @@ const StreamMetadataResponseSchema = { // never client-writable or grantable. type: { minLength: 1, type: "string" }, }, - required: [ - "schema", - "granted", - "exact_filter", - "range_filter", - "lexical_search", - "semantic_search", - "aggregation", - ], + required: ["schema", "granted", "lexical_search", "semantic_search", "aggregation"], type: "object", }, ], @@ -1628,7 +2122,7 @@ export const publicManifests = [ 200: { schema: ProtectedResourceMetadataSchema }, }, summary: - "Return RFC 9728 protected-resource metadata advertising the PDPP query base, owner-self-export, advisory `pdpp_agent_discovery` / `pdpp_owner_agent_onboarding` when safely configured, and capabilities such as `client_event_subscriptions`.", + "Return RFC 9728 protected-resource metadata advertising the optional provider-native `pdpp_source_declaration_uri`, the PDPP query base, owner-self-export, advisory `pdpp_agent_discovery` / `pdpp_owner_agent_onboarding` when safely configured, and capabilities such as `client_event_subscriptions`.", surface: "public", tags: ["metadata"], }, @@ -1685,6 +2179,26 @@ export const publicManifests = [ surface: "public", tags: ["grants"], }, + { + id: "reviewConsent", + method: "POST", + path: "/consent/review", + request: { + body: { + contentType: "application/json", + schema: { oneOf: [SingleApprovalReviewRequestSchema, BatchApprovalReviewRequestSchema] }, + }, + }, + responses: { + 200: { description: "Approval review finalized", schema: BatchApprovalReviewResponseSchema }, + 400: { description: "Invalid request", schema: ErrorObjectSchema }, + 403: { description: "Grant is malformed or no longer valid", schema: ErrorObjectSchema }, + 404: { description: "Pending consent request not found", schema: ErrorObjectSchema }, + }, + summary: "Finalize a consent review before approval.", + surface: "public", + tags: ["grants"], + }, { id: "approveConsent", method: "POST", @@ -1692,41 +2206,18 @@ export const publicManifests = [ request: { body: { contentType: "application/json", - schema: { - additionalProperties: false, - properties: { - ai_training_consented: { type: "boolean" }, - approved_source_indexes: { - oneOf: [ - { minimum: 0, type: "integer" }, - { pattern: "^[0-9]+$", type: "string" }, - { - items: { - oneOf: [ - { minimum: 0, type: "integer" }, - { pattern: "^[0-9]+$", type: "string" }, - ], - }, - type: "array", - }, - ], - }, - confirm_approve_all: { - oneOf: [{ type: "boolean" }, { enum: ["true", "1", "on"], type: "string" }], - }, - request_uri: NonEmptyStringSchema, - subject_id: NonEmptyStringSchema, - }, - required: ["request_uri"], - type: "object", - }, + schema: ApproveConsentRequestSchema, }, }, responses: { - 200: { description: "Grant approved and client token issued", schema: GrantApprovalResponseSchema }, + 200: { + description: "Grant approved and client token issued", + schema: { oneOf: [GrantApprovalResponseSchema, BatchGrantApprovalResponseSchema] }, + }, 400: { description: "Invalid request", schema: ErrorObjectSchema }, 403: { description: "Grant is malformed or no longer valid", schema: ErrorObjectSchema }, 404: { description: "Pending consent request not found", schema: ErrorObjectSchema }, + 409: { description: "Pending consent approval conflict", schema: ErrorObjectSchema }, }, summary: "Approve a pending data-access request through the JSON consent surface used by tests and automation.", surface: "public", @@ -1810,8 +2301,9 @@ export const publicManifests = [ responses: { 200: { schema: IntrospectionResponseSchema }, 400: { description: "Missing token parameter", schema: ErrorObjectSchema }, + 401: { description: "Confidential resource-server authentication failed", schema: ErrorObjectSchema }, }, - summary: "Inspect token activity and, for active client tokens, the bound grant projection.", + summary: "Inspect token activity for an authenticated confidential resource server.", surface: "public", tags: ["oauth"], }, @@ -1885,7 +2377,7 @@ export const publicManifests = [ ...ProtectedReadErrors, }, summary: - "List streams available under the current grant or owner scope. Returns stream-level totals only; for per-field filter capabilities (exact, range operators, aggregation) call `GET /v1/schema` first and consult `field_capabilities` per stream before issuing `filter[...]` queries on `/v1/streams/{stream}/records`. Multi-connection deployments emit one entry per (stream, connection_id); each entry carries `connection_id` and a `display_name` so callers can attribute and disambiguate.", + "List streams available under the current grant or owner scope. Returns stream-level totals only. Owner-token current-capability callers can consult `GET /v1/schema` for per-field filter capabilities; client-token v0.1 reads reject `filter[...]`. Multi-connection deployments emit one entry per (stream, connection_id); each entry carries `connection_id` and a `display_name` so callers can attribute and disambiguate.", surface: "public", tags: ["records"], }, @@ -1912,7 +2404,7 @@ export const publicManifests = [ ...ProtectedReadErrors, }, summary: - "Return stream metadata including declared query capabilities and advisory freshness. For per-field filter capabilities on this stream (exact, range operators, aggregation), prefer `GET /v1/schema` first and read `field_capabilities` rather than guessing `filter[...]` shapes against the records endpoint. Pass `connection_id` (or the deprecated `connector_instance_id` alias) to restrict to a single connection; omitted, the response aggregates across the connections the grant authorizes.", + "Return stream metadata including declared query capabilities and advisory freshness. Owner-token current-capability callers can consult `GET /v1/schema` for per-field filter capabilities; client-token v0.1 metadata does not advertise typed filter capabilities and client reads reject `filter[...]`. Pass `connection_id` (or the deprecated `connector_instance_id` alias) to restrict to a single connection; omitted, the response aggregates across the connections the grant authorizes.", surface: "public", tags: ["records"], }, @@ -1930,7 +2422,7 @@ export const publicManifests = [ ...ListRecordErrors, }, summary: - "List records in a stream under grant enforcement. Supports logical-cursor pagination, exact and declared range filters, declared one-hop expansion, and changes_since. Per-field filter operators, sortable fields, expandable relations, projection, search modes, and count support are advertised by `GET /v1/schema` (`field_capabilities`, `expand_capabilities`); consult it before issuing `filter[...]`, `expand[]`, or `fields=` shapes to avoid 400 errors. Pass `connection_id` to restrict to one connection; the deprecated `connector_instance_id` alias is accepted for compatibility but new clients SHOULD use `connection_id`.", + "List records in a stream under grant enforcement. Supports logical-cursor pagination, declared one-hop expansion, and changes_since. Client-token v0.1 reads reject exact and range `filter[...]` parameters before consulting current source metadata; owner-token current-capability reads MAY use declared filters. Per-field query capabilities are advertised by `GET /v1/schema`; consult it before issuing supported query shapes. Pass `connection_id` to restrict to one connection; the deprecated `connector_instance_id` alias is accepted for compatibility but new clients SHOULD use `connection_id`.", surface: "public", tags: ["records"], }, @@ -1948,7 +2440,7 @@ export const publicManifests = [ ...ProtectedReadErrors, }, summary: - "Compute a single-stream grant-safe aggregation. Supports count, numeric sum, numeric/date min/max, exact count_distinct, scalar grouped counts (`group_by`), calendar time-bucket counts (`group_by_time`+`granularity`, optional `time_zone` defaulting to UTC), and existing exact/range filters over declared fields. Exactly one grouping dimension per call: `group_by` XOR `group_by_time`. Grouped responses include `other_count` (sum of counts for groups/buckets beyond `limit`) so callers can detect truncation without a second round trip.", + "Compute a single-stream grant-safe aggregation. Supports count, numeric sum, numeric/date min/max, exact count_distinct, scalar grouped counts (`group_by`), calendar time-bucket counts (`group_by_time`+`granularity`, optional `time_zone` defaulting to UTC), and owner-token current-capability exact/range filters over declared fields. Client-token v0.1 reads reject `filter[...]`. Exactly one grouping dimension per call: `group_by` XOR `group_by_time`. Grouped responses include `other_count` (sum of counts for groups/buckets beyond `limit`) so callers can detect truncation without a second round trip.", surface: "public", tags: ["records"], }, @@ -2079,7 +2571,7 @@ export const publicManifests = [ 410: { description: "Cursor expired or refers to an unknown snapshot", schema: ErrorObjectSchema }, }, summary: - "Optional lexical retrieval extension: search records across authorized streams by text. Search modes, per-mode cursor support, and field-level `lexical_search`/`semantic_search` capabilities are advertised by `GET /v1/schema`; `filter[...]` operators applied to a single named stream must come from that stream's `field_capabilities`. Hits carry `connection_id` for attribution; the deprecated `connector_instance_id` alias is emitted alongside for compatibility but new clients SHOULD read `connection_id`.", + "Optional lexical retrieval extension: search records across authorized streams by text. Search modes, per-mode cursor support, and field-level `lexical_search`/`semantic_search` capabilities are advertised by `GET /v1/schema`. Client-token v0.1 reads reject `filter[...]`; owner-token current-capability reads MAY use declared filters. Hits carry `connection_id` for attribution; the deprecated `connector_instance_id` alias is emitted alongside for compatibility but new clients SHOULD read `connection_id`.", surface: "public", tags: ["records", "lexical-retrieval"], }, diff --git a/packages/reference-contract/src/reference/index.ts b/packages/reference-contract/src/reference/index.ts index 7072345e9..d6950fece 100644 --- a/packages/reference-contract/src/reference/index.ts +++ b/packages/reference-contract/src/reference/index.ts @@ -949,6 +949,158 @@ const ApprovalItemSchema = { type: "object", }; +const ApprovalIdParamSchema = { + additionalProperties: false, + properties: { approvalId: { minLength: 1, type: "string" } }, + required: ["approvalId"], + type: "object", +}; + +const ApprovalReviewSecretPropertyPattern = + "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$"; + +function approvalReviewJsonSchema(depth = 4): Record<string, unknown> { + const next = depth > 0 ? approvalReviewJsonSchema(depth - 1) : {}; + return { + oneOf: [ + { type: ["boolean", "null", "number", "string"] }, + { items: next, type: "array" }, + { + additionalProperties: next, + propertyNames: { not: { pattern: ApprovalReviewSecretPropertyPattern } }, + type: "object", + }, + ], + }; +} + +const ConsentApprovalReviewSchema = { + additionalProperties: false, + properties: { + approval_id: { type: "string" }, + client: { + additionalProperties: false, + properties: { + client_id: { type: "string" }, + display: { + additionalProperties: false, + properties: { + name: { type: ["string", "null"] }, + policy_uri: { type: ["string", "null"] }, + tos_uri: { type: ["string", "null"] }, + uri: { type: ["string", "null"] }, + }, + required: ["name", "policy_uri", "tos_uri", "uri"], + type: "object", + }, + registration_mode: { type: "string" }, + }, + required: ["client_id", "display", "registration_mode"], + type: "object", + }, + created_at: { type: "string" }, + expires_at: { type: "string" }, + grant_outcome: { + additionalProperties: false, + properties: { + access_mode: { type: "string" }, + description: { type: "string" }, + }, + required: ["access_mode", "description"], + type: "object", + }, + kind: { const: "consent" }, + object: { const: "approval_review" }, + purpose: { + additionalProperties: false, + properties: { + code: { type: ["string", "null"] }, + description: { type: ["string", "null"] }, + }, + required: ["code", "description"], + type: "object", + }, + retention: approvalReviewJsonSchema(), + source: { + oneOf: [ + { + additionalProperties: false, + properties: { + id: { type: "string" }, + kind: { enum: ["connector", "provider_native"], type: "string" }, + }, + required: ["id", "kind"], + type: "object", + }, + { type: "null" }, + ], + }, + streams: { + items: { + additionalProperties: false, + properties: { + client_claims: approvalReviewJsonSchema(), + connection_id: { type: ["string", "null"] }, + fields: { oneOf: [{ items: { type: "string" }, type: "array" }, { type: "null" }] }, + name: { type: "string" }, + necessity: { type: ["string", "null"] }, + resources: { oneOf: [{ items: approvalReviewJsonSchema(), type: "array" }, { type: "null" }] }, + time_range: { + oneOf: [ + { + additionalProperties: false, + properties: { since: { type: ["string", "null"] } }, + required: ["since"], + type: "object", + }, + { type: "null" }, + ], + }, + view: { type: ["string", "null"] }, + }, + required: ["client_claims", "connection_id", "fields", "name", "necessity", "resources", "time_range", "view"], + type: "object", + }, + type: "array", + }, + trust: { const: "unverified" }, + }, + required: [ + "object", + "approval_id", + "client", + "created_at", + "expires_at", + "grant_outcome", + "kind", + "purpose", + "retention", + "source", + "streams", + "trust", + ], + type: "object", +}; + +const OwnerDeviceApprovalReviewSchema = { + additionalProperties: false, + properties: { + approval_id: { type: "string" }, + client_id: { type: "string" }, + created_at: { type: "string" }, + expires_at: { type: "string" }, + kind: { const: "owner_device" }, + object: { const: "approval_review" }, + }, + required: ["object", "approval_id", "client_id", "kind", "created_at", "expires_at"], + type: "object", +}; + +const ApprovalReviewSchema = { + oneOf: [ConsentApprovalReviewSchema, OwnerDeviceApprovalReviewSchema], + type: "object", +}; + const RefSearchRecordSchema = { additionalProperties: true, properties: { @@ -2387,6 +2539,17 @@ export const referenceManifests = [ surface: "reference", tags: ["reference", "grants"], }, + { + id: "refGetApproval", + method: "GET", + path: "/_ref/approvals/{approvalId}", + request: { params: ApprovalIdParamSchema }, + responses: { 200: { schema: ApprovalReviewSchema }, ...CommonErrors }, + summary: + "Get one pending approval review by opaque approval_id. The reference projection excludes device-flow credentials and raw persisted request payloads.", + surface: "reference", + tags: ["reference", "grants"], + }, { id: "refCreateDeviceExporterEnrollmentCode", method: "POST", diff --git a/packages/reference-contract/test/connection-identity.test.ts b/packages/reference-contract/test/connection-identity.test.ts index 0790c6526..64d9ec572 100644 --- a/packages/reference-contract/test/connection-identity.test.ts +++ b/packages/reference-contract/test/connection-identity.test.ts @@ -53,7 +53,6 @@ test("every grant-authorized read operation accepts optional connection_id and c test("stream list response items carry connection_id and display_name", () => { const schema = responseSchema("listStreams"); - // biome-ignore lint/suspicious/noUnnecessaryConditions: tsc (noUncheckedIndexedAccess) requires this chain; biome's simpler type model disagrees on the Record<string, JsonSchema> index access. const item = schema?.properties?.data?.items; assert.ok(item, "listStreams 200 must declare data items"); assert.ok(item.properties?.connection_id, "stream list item must declare connection_id"); @@ -74,7 +73,6 @@ test("record response carries connection_id and display_name", () => { test("search result items carry connection_id and display_name on lexical/semantic/hybrid", () => { for (const id of ["searchRecordsLexical", "searchRecordsSemantic", "searchRecordsHybrid"]) { - // biome-ignore lint/suspicious/noUnnecessaryConditions: tsc (noUncheckedIndexedAccess) requires this chain; biome's simpler type model disagrees on the Record<string, JsonSchema> index access. const item = responseSchema(id)?.properties?.data?.items; assert.ok(item, `${id} 200 must declare data items`); assert.ok(item.properties?.connection_id, `${id} hit must declare connection_id`); @@ -91,7 +89,6 @@ test("getRecord and getBlob declare a typed ambiguous_connection 409 envelope", const errorSchema = response.schema?.properties?.error; assert.ok(errorSchema, `${id} 409 must declare error envelope`); assert.equal( - // biome-ignore lint/suspicious/noUnnecessaryConditions: tsc (noUncheckedIndexedAccess) requires this chain; biome's simpler type model disagrees on the Record<string, JsonSchema> index access. errorSchema.properties?.code?.const, "ambiguous_connection", `${id} 409 must use code "ambiguous_connection"` @@ -100,7 +97,6 @@ test("getRecord and getBlob declare a typed ambiguous_connection 409 envelope", const retryWith = errorSchema.properties?.retry_with; assert.ok(retryWith, `${id} 409 must include retry_with`); assert.equal( - // biome-ignore lint/suspicious/noUnnecessaryConditions: tsc (noUncheckedIndexedAccess) requires this chain; biome's simpler type model disagrees on the Record<string, JsonSchema> index access. retryWith.properties?.field?.const, "connection_id", `${id} retry_with.field must point at connection_id` diff --git a/packages/reference-contract/test/llm-hints.test.ts b/packages/reference-contract/test/llm-hints.test.ts index 38a4201d0..06a68f629 100644 --- a/packages/reference-contract/test/llm-hints.test.ts +++ b/packages/reference-contract/test/llm-hints.test.ts @@ -13,9 +13,10 @@ import { publicManifests as publicManifestsRaw } from "../src/public/index.ts"; const publicManifests = publicManifestsRaw as readonly RouteManifest[]; const FIELD_CAPABILITIES_OR_FILTER_RE = /field_capabilities|filter/i; -const FIELD_CAPABILITIES_RE = /field_capabilities/; +const CLIENT_TOKEN_V01_REJECTION_RE = /client-token v0\.1 reads reject/i; const HYBRID_PAGINATION_RE = /hybrid_pagination_supported/; const LEXICAL_FALLBACK_RE = /lexical|\/v1\/search\b/; +const OWNER_TOKEN_CURRENT_CAPABILITY_RE = /owner-token current-capability/i; const STREAM_PARAMETER_RE = /stream=<name>/; const V1_SCHEMA_RE = /\/v1\/schema/; const VIEW_COMPACT_RE = /view=compact/; @@ -77,12 +78,16 @@ test("searchRecordsHybrid summary references hybrid_pagination_supported and lex ); }); -test("ListRecordsQuerySchema.filter description references field_capabilities and /v1/schema", () => { +test("ListRecordsQuerySchema.filter description distinguishes owner and v0.1 client capabilities", () => { const listRecords = findOperation("listRecords"); const filterSchema = listRecords.request?.query?.properties?.filter; assert.ok(filterSchema, "listRecords query must declare a filter property"); const { description } = filterSchema; assert.equal(typeof description, "string"); - assert.match(description ?? "", FIELD_CAPABILITIES_RE, "filter.description must name field_capabilities"); - assert.match(description ?? "", V1_SCHEMA_RE, "filter.description must reference /v1/schema"); + assert.match(description ?? "", OWNER_TOKEN_CURRENT_CAPABILITY_RE, "filter.description must preserve owner filters"); + assert.match( + description ?? "", + CLIENT_TOKEN_V01_REJECTION_RE, + "filter.description must state that v0.1 client reads reject filters" + ); }); diff --git a/packages/reference-contract/test/source-declaration-discovery.test.ts b/packages/reference-contract/test/source-declaration-discovery.test.ts new file mode 100644 index 000000000..08006d803 --- /dev/null +++ b/packages/reference-contract/test/source-declaration-discovery.test.ts @@ -0,0 +1,187 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + deriveProtectedResourceMetadataUrl, + hasExactProtectedResourceIdentity, + validateProviderNativeDiscoveryMetadata, + validateResponse, +} from "../src/index.ts"; + +const RESOURCE = "https://resource.example.com/owner/alice?tenant=primary"; + +function protectedResourceMetadata() { + return { + authorization_servers: ["https://authorization.example.com"], + bearer_methods_supported: ["header"], + pdpp_core_query_base: "https://resource.example.com/v1", + pdpp_provider_connect_version: "0.1", + pdpp_self_export_supported: true, + pdpp_token_kinds_supported: ["owner", "client"], + resource: RESOURCE, + resource_name: "Example resource", + }; +} + +test("generic protected-resource metadata keeps the provider-native pointer optional and constrained", () => { + assert.deepEqual( + validateResponse("getProtectedResourceMetadata", { + body: protectedResourceMetadata(), + status: 200, + }), + { ok: true, skipped: false } + ); + + assert.deepEqual( + validateResponse("getProtectedResourceMetadata", { + body: { + ...protectedResourceMetadata(), + pdpp_source_declaration_uri: "https://declarations.example.com/source.json", + }, + status: 200, + }), + { ok: true, skipped: false } + ); + + for (const pointer of [ + "http://declarations.example.com/source.json", + "https://declarations.example.com/source.json#latest", + "https://user@declarations.example.com/source.json", + "https://user:password@declarations.example.com/source.json", + "https:///source.json", + "https://declarations.example.com/%", + "https://declarations.example.com:", + "https://declarations.example.com/source.json\u0000", + "https://declarations.example.com/source.json\n", + "https://declarations.example.com/source.json\t", + "https://éxample.com/source.json", + ["https://declarations.example.com/source.json"], + ]) { + const body = { ...protectedResourceMetadata(), pdpp_source_declaration_uri: pointer }; + assert.equal(validateResponse("getProtectedResourceMetadata", { body, status: 200 }).ok, false); + } +}); + +test("provider-native discovery requires one valid pointer and exact resource identity", () => { + const valid = { + ...protectedResourceMetadata(), + pdpp_source_declaration_uri: "https://declarations.example.com/source.json", + }; + assert.deepEqual(validateProviderNativeDiscoveryMetadata(RESOURCE, valid), { + ok: true, + sourceDeclarationUri: "https://declarations.example.com/source.json", + }); + + for (const pointer of [ + undefined, + "http://declarations.example.com/source.json", + "https://declarations.example.com/source.json#latest", + "https://user@declarations.example.com/source.json", + "https://user:password@declarations.example.com/source.json", + ["https://declarations.example.com/source.json"], + ]) { + assert.deepEqual( + validateProviderNativeDiscoveryMetadata(RESOURCE, { + ...protectedResourceMetadata(), + pdpp_source_declaration_uri: pointer, + }), + { ok: false, reason: "invalid_source_declaration_uri" } + ); + } + + assert.deepEqual(validateProviderNativeDiscoveryMetadata("https://resource.example.com/another-owner", valid), { + ok: false, + reason: "resource_mismatch", + }); + + for (const invalidResource of [ + "http://resource.example.com", + "https://user@resource.example.com", + "https://resource.example.com#fragment", + "https:///owner/alice", + "https://resource.example.com/%", + "https://resource.example.com:", + "https://resource.example.com/owner/alice\u0000", + "https://resource.example.com/owner/alice\n", + "https://éxample.com/owner/alice", + "not a URI", + ]) { + assert.deepEqual( + validateProviderNativeDiscoveryMetadata(invalidResource, { + ...valid, + resource: invalidResource, + }), + { ok: false, reason: "invalid_resource" } + ); + } + + assert.deepEqual( + validateProviderNativeDiscoveryMetadata(RESOURCE, { ...valid, resource: "https://resource.example.com/%" }), + { ok: false, reason: "invalid_resource" } + ); + + for (const pointer of [ + "https:///source.json", + "https://declarations.example.com/%", + "https://declarations.example.com:", + "https://declarations.example.com/source.json\u0000", + "https://declarations.example.com/source.json\n", + "https://declarations.example.com/source.json\t", + "https://éxample.com/source.json", + ]) { + assert.deepEqual( + validateProviderNativeDiscoveryMetadata(RESOURCE, { ...valid, pdpp_source_declaration_uri: pointer }), + { ok: false, reason: "invalid_source_declaration_uri" } + ); + } +}); + +test("RFC 9728 metadata URLs insert the well-known path before resource path and query", () => { + assert.equal( + deriveProtectedResourceMetadataUrl("https://resource.example.com"), + "https://resource.example.com/.well-known/oauth-protected-resource" + ); + assert.equal( + deriveProtectedResourceMetadataUrl("https://resource.example.com/resource1"), + "https://resource.example.com/.well-known/oauth-protected-resource/resource1" + ); + assert.equal( + deriveProtectedResourceMetadataUrl("https://resource.example.com?tenant=primary"), + "https://resource.example.com/.well-known/oauth-protected-resource?tenant=primary" + ); + assert.equal( + deriveProtectedResourceMetadataUrl("https://resource.example.com/"), + "https://resource.example.com/.well-known/oauth-protected-resource" + ); + assert.equal( + deriveProtectedResourceMetadataUrl("https://resource.example.com/?tenant=primary"), + "https://resource.example.com/.well-known/oauth-protected-resource?tenant=primary" + ); + assert.equal( + deriveProtectedResourceMetadataUrl(RESOURCE), + "https://resource.example.com/.well-known/oauth-protected-resource/owner/alice?tenant=primary" + ); + assert.throws(() => deriveProtectedResourceMetadataUrl("http://resource.example.com"), TypeError); + assert.throws(() => deriveProtectedResourceMetadataUrl("https://resource.example.com#fragment"), TypeError); + assert.throws(() => deriveProtectedResourceMetadataUrl("https://user@resource.example.com"), TypeError); + assert.throws(() => deriveProtectedResourceMetadataUrl("https://resource.example.com/%"), TypeError); + assert.throws(() => deriveProtectedResourceMetadataUrl("https://resource.example.com:"), TypeError); + assert.throws(() => deriveProtectedResourceMetadataUrl("https://resource.example.com\u0000"), TypeError); + assert.throws(() => deriveProtectedResourceMetadataUrl("https://resource.example.com\n"), TypeError); + assert.throws(() => deriveProtectedResourceMetadataUrl("https://éxample.com"), TypeError); + assert.throws(() => deriveProtectedResourceMetadataUrl("https://resource.example.com\\resource1"), TypeError); +}); + +test("RFC 9728 returned-resource validation uses exact string identity", () => { + assert.equal(hasExactProtectedResourceIdentity(RESOURCE, RESOURCE), true); + assert.equal( + hasExactProtectedResourceIdentity(RESOURCE, "https://resource.example.com/owner/alice?tenant=secondary"), + false + ); + assert.equal( + hasExactProtectedResourceIdentity(RESOURCE, "https://RESOURCE.example.com/owner/alice?tenant=primary"), + false + ); +}); diff --git a/packages/reference-contract/test/surface.test.ts b/packages/reference-contract/test/surface.test.ts index 025ece8dc..f18a5822a 100644 --- a/packages/reference-contract/test/surface.test.ts +++ b/packages/reference-contract/test/surface.test.ts @@ -13,6 +13,7 @@ import { } from "../src/public/index.ts"; const CONSENT_APPROVE_RE = /consent\/approve.*\{ grant_id, token, grant \}/; +const CONSENT_REVIEW_RE = /\/consent\/review/; const GRANT_REVOKE_RE = /\/grants\/\{grantId\}\/revoke/; const OAUTH_PAR_RE = /\/oauth\/par/; const OAUTH_TOKEN_RE = /\/oauth\/token/; @@ -29,6 +30,7 @@ test("public manifests cover metadata, auth, grant, and record surfaces", () => "getProtectedResourceMetadata", "registerDynamicClient", "createPushedAuthorizationRequest", + "reviewConsent", "approveConsent", "startOwnerDeviceAuthorization", "exchangeOwnerDeviceToken", @@ -45,6 +47,7 @@ test("public manifests cover metadata, auth, grant, and record surfaces", () => const publicOperations = listOperations().filter((entry) => entry.surface === "public"); assert.ok(publicOperations.some((entry) => entry.id === "createPushedAuthorizationRequest")); + assert.ok(publicOperations.some((entry) => entry.id === "reviewConsent")); assert.ok(publicOperations.some((entry) => entry.id === "revokeGrant")); }); @@ -65,6 +68,129 @@ test("request validators accept the shipped public flow shapes", () => { }); assert.deepEqual(parRequest, { ok: true }); + const batchReviewRequest = validateRequest("reviewConsent", { + body: { + approved_source_indexes: [0], + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + source_narrowing: { + 0: { + fields: { top_artists: ["id"] }, + since: { top_artists: "2026-01-01T00:00:00Z" }, + streams: ["top_artists"], + }, + }, + }, + }); + assert.deepEqual(batchReviewRequest, { ok: true }); + + const singleReviewRequest = validateRequest("reviewConsent", { + body: { + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + }, + }); + assert.deepEqual(singleReviewRequest, { ok: true }); + + const singleAiTrainingBooleanReviewRequest = validateRequest("reviewConsent", { + body: { + ai_training_consented: true, + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + subject_id: "owner_local", + }, + }); + assert.deepEqual(singleAiTrainingBooleanReviewRequest, { ok: true }); + + const singleAiTrainingFormReviewRequest = validateRequest("reviewConsent", { + body: { + ai_training_consented: "1", + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + subject_id: "owner_local", + }, + }); + assert.deepEqual(singleAiTrainingFormReviewRequest, { ok: true }); + + const singleAiTrainingMalformedReviewRequest = validateRequest("reviewConsent", { + body: { + ai_training_consented: "yes", + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + subject_id: "owner_local", + }, + }); + assert.equal(singleAiTrainingMalformedReviewRequest.ok, false); + + const approvalIdReviewRequest = validateRequest("reviewConsent", { + body: { + approval_id: "appr_public_reference_id", + }, + }); + assert.deepEqual(approvalIdReviewRequest, { ok: true }); + + const reviewRejectsBothIdentifiers = validateRequest("reviewConsent", { + body: { + approval_id: "appr_public_reference_id", + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + }, + }); + assert.equal(reviewRejectsBothIdentifiers.ok, false); + + const batchReviewRejectsUnknownShape = validateRequest("reviewConsent", { + body: { + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + source_narrowing: { 0: { arbitrary: true } }, + }, + }); + assert.equal(batchReviewRejectsUnknownShape.ok, false); + + const batchReviewRejectsBadKey = validateRequest("reviewConsent", { + body: { + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + source_narrowing: { nope: { streams: ["top_artists"] } }, + }, + }); + assert.equal(batchReviewRejectsBadKey.ok, false); + + const singleApprovalRequest = validateRequest("approveConsent", { + body: { + approval_review_revision: "reference.approval-review.v1:sha256:test", + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + }, + }); + assert.deepEqual(singleApprovalRequest, { ok: true }); + + const batchApprovalRequest = validateRequest("approveConsent", { + body: { + approval_review_revision: "reference.batch-approval-review.v1:sha256:test", + confirm_reviewed_decision: "1", + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + }, + }); + assert.deepEqual(batchApprovalRequest, { ok: true }); + + const batchApprovalRequiresReview = validateRequest("approveConsent", { + body: { + confirm_reviewed_decision: "1", + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + }, + }); + assert.equal(batchApprovalRequiresReview.ok, false); + + const finalApprovalRejectsSubjectReplay = validateRequest("approveConsent", { + body: { + approval_review_revision: "reference.approval-review.v1:sha256:test", + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + subject_id: "owner_local", + }, + }); + assert.equal(finalApprovalRejectsSubjectReplay.ok, false); + + const finalApprovalRejectsAiTrainingReplay = validateRequest("approveConsent", { + body: { + ai_training_consented: true, + approval_review_revision: "reference.approval-review.v1:sha256:test", + request_uri: "urn:ietf:params:oauth:request_uri:pdpp:pending:dev_test", + }, + }); + assert.equal(finalApprovalRejectsAiTrainingReplay.ok, false); + const deviceAuthRequest = validateRequest("startOwnerDeviceAuthorization", { body: { audience: "pdpp", @@ -148,6 +274,145 @@ test("listRecords response validator accepts runtime warning parameters", () => assert.deepEqual(result, { ok: true, skipped: false }); }); +test("ref approval review contract enforces consent and owner-device shapes", () => { + const consent = validateResponse("refGetApproval", { + body: { + approval_id: "apr_review", + client: { + client_id: "concert_finder", + display: { + name: "Concert Finder", + policy_uri: "https://concert.example/policy", + tos_uri: null, + uri: "https://concert.example", + }, + registration_mode: "pre_registered_public", + }, + created_at: "2026-08-11T12:00:00.000Z", + expires_at: "2026-08-11T12:10:00.000Z", + grant_outcome: { + access_mode: "continuous", + description: "Ongoing access; this reference implementation sets no grant expiry.", + }, + kind: "consent", + object: "approval_review", + purpose: { code: null, description: "Suggest concerts." }, + retention: { period: "P30D" }, + source: { id: "spotify", kind: "connector" }, + streams: [ + { + client_claims: { commitment: "delete after use" }, + connection_id: "cin_music", + fields: null, + name: "top_artists", + necessity: null, + resources: null, + time_range: null, + view: "basic", + }, + ], + trust: "unverified", + }, + status: 200, + }); + assert.deepEqual(consent, { ok: true, skipped: false }); + + const ownerDevice = validateResponse("refGetApproval", { + body: { + approval_id: "apr_owner", + client_id: "owner_cli", + created_at: "2026-08-11T12:00:00.000Z", + expires_at: "2026-08-11T12:10:00.000Z", + kind: "owner_device", + object: "approval_review", + }, + status: 200, + }); + assert.deepEqual(ownerDevice, { ok: true, skipped: false }); + + const leakedNestedField = validateResponse("refGetApproval", { + body: { + approval_id: "apr_review", + client: { + client_id: "concert_finder", + display: { + logo_uri: "https://concert.example/logo.png", + name: "Concert Finder", + policy_uri: null, + tos_uri: null, + uri: null, + }, + registration_mode: "pre_registered_public", + }, + created_at: "2026-08-11T12:00:00.000Z", + expires_at: "2026-08-11T12:10:00.000Z", + grant_outcome: { access_mode: "continuous", description: "Ongoing access." }, + kind: "consent", + object: "approval_review", + purpose: { code: null, description: null }, + retention: null, + source: null, + streams: [], + trust: "unverified", + }, + status: 200, + }); + assert.equal(leakedNestedField.ok, false); + + const leakedSecretJson = validateResponse("refGetApproval", { + body: { + approval_id: "apr_review", + client: { + client_id: "concert_finder", + display: { + name: "Concert Finder", + policy_uri: null, + tos_uri: null, + uri: null, + }, + registration_mode: "pre_registered_public", + }, + created_at: "2026-08-11T12:00:00.000Z", + expires_at: "2026-08-11T12:10:00.000Z", + grant_outcome: { access_mode: "continuous", description: "Ongoing access." }, + kind: "consent", + object: "approval_review", + purpose: { code: null, description: null }, + retention: { nested: { Authorization: "Bearer token-value" } }, + source: null, + streams: [ + { + client_claims: { clientSecret: "secret-value" }, + connection_id: null, + fields: null, + name: "top_artists", + necessity: null, + resources: [{ "api-key": "secret-value" }], + time_range: null, + view: null, + }, + ], + trust: "unverified", + }, + status: 200, + }); + assert.equal(leakedSecretJson.ok, false); + + const mixedVariant = validateResponse("refGetApproval", { + body: { + approval_id: "apr_owner", + client_id: "owner_cli", + client: { client_id: "should_not_be_here" }, + created_at: "2026-08-11T12:00:00.000Z", + expires_at: "2026-08-11T12:10:00.000Z", + kind: "owner_device", + object: "approval_review", + }, + status: 200, + }); + assert.equal(mixedVariant.ok, false); +}); + test("registerDynamicClient response omits unset optional URI metadata", () => { const minimal = validateResponse("registerDynamicClient", { body: { @@ -203,6 +468,7 @@ test("OpenAPI and docs generation include the auth/control routes alongside reco assert.ok(publicDocument.paths["/.well-known/oauth-authorization-server"]); assert.ok(publicDocument.paths["/.well-known/oauth-protected-resource"]); assert.ok(publicDocument.paths["/oauth/par"]); + assert.ok(publicDocument.paths["/consent/review"]); assert.ok(publicDocument.paths["/oauth/token"]); assert.ok(publicDocument.paths["/grants/{grantId}/revoke"]); assert.equal(publicDocument.paths["/_ref/connectors"], undefined); @@ -221,6 +487,7 @@ test("OpenAPI and docs generation include the auth/control routes alongside reco assert.equal(reconcileOperation.post.operationId, "refDatasetSummaryReconcile"); assert.match(docs.routes, OAUTH_PAR_RE); + assert.match(docs.routes, CONSENT_REVIEW_RE); assert.match(docs.routes, OAUTH_TOKEN_RE); assert.match(docs.routes, GRANT_REVOKE_RE); assert.match(docs.routes, RECORDS_ROUTE_RE); diff --git a/packages/reference-contract/test/validate-response.test.ts b/packages/reference-contract/test/validate-response.test.ts index 52b528d9e..437c0749d 100644 --- a/packages/reference-contract/test/validate-response.test.ts +++ b/packages/reference-contract/test/validate-response.test.ts @@ -89,6 +89,69 @@ test("validateResponse fails closed when payload violates declared response sche } }); +test("approveConsent accepts the closed batch approval response", () => { + const result = validateResponse("approveConsent", { + body: { + grant: { + child_grants: [{ grant_id: "grant_child_1", source: { id: "spotify" } }], + grant_id: "grant_package", + package: true, + package_id: "package_1", + }, + package_id: "package_1", + token: "token_1", + }, + status: 200, + }); + assert.deepEqual(result, { ok: true, skipped: false }); +}); + +test("OAuth token error contract accepts the refresh-reuse recovery marker", () => { + const result = validateResponse("exchangeOwnerDeviceToken", { + body: { + error: "invalid_grant", + error_description: "Refresh token reuse revoked its family; fresh authorization is required", + fresh_authorization_required: true, + request_id: "req_refresh_reuse", + }, + status: 400, + }); + assert.deepEqual(result, { ok: true, skipped: false }); +}); + +test("approveConsent rejects extra fields in a batch approval response", () => { + const result = validateResponse("approveConsent", { + body: { + grant: { + child_grants: [{ grant_id: "grant_child_1", source: { id: "spotify" } }], + grant_id: "grant_package", + package: true, + package_id: "package_1", + }, + package_id: "package_1", + token: "token_1", + unexpected: true, + }, + status: 200, + }); + assert.equal(result.ok, false); +}); + +test("introspection contract declares confidential caller authentication failure", () => { + const result = validateResponse("introspectToken", { + body: { + error: { + code: "context.authentication_failed", + message: "Introspection client authentication failed", + request_id: "req_introspection_auth", + type: "https://pdpp.org/errors/context.authentication_failed", + }, + }, + status: 401, + }); + assert.deepEqual(result, { ok: true, skipped: false }); +}); + // `expand_capabilities` target-naming contract. Each entry SHALL carry both // `target_stream` (the related child stream) and `child_parent_key_field` (the // field on the child holding the parent's key). Pinned via getStreamMetadata's diff --git a/reference-implementation/README.md b/reference-implementation/README.md index 6a6270093..e427b2d17 100644 --- a/reference-implementation/README.md +++ b/reference-implementation/README.md @@ -103,11 +103,11 @@ mint bearer tokens. Overrides: -- `PDPP_DCR_INITIAL_ACCESS_TOKENS=token1,token2` — comma-separated initial +- `PDPP_DCR_INITIAL_ACCESS_TOKENS=token1,token2` - comma-separated initial access tokens for optional operator/bootstrap registration. If a caller sends a bearer token, it must be one of these tokens; callers can omit the bearer token for public self-registration. -- `PDPP_ENABLE_DYNAMIC_CLIENT_REGISTRATION=0` — explicitly disables DCR. The +- `PDPP_ENABLE_DYNAMIC_CLIENT_REGISTRATION=0` - explicitly disables DCR. The AS metadata then omits `registration_endpoint` and advertises only `pdpp_registration_modes_supported: ["pre_registered_public"]`. @@ -122,9 +122,15 @@ deployments should supply their own `preRegisteredPublicClients` option. ### Consent and grant issuance - `GET /consent?request_uri=...` +- `POST /consent/review` - `POST /consent/approve` - `POST /consent/deny` +`POST /consent/review` returns the exact `approval_review` artifact and +`approval_review_revision`. Final `POST /consent/approve` sends the +`request_uri` and that revision only. Batch approval also sends +`confirm_reviewed_decision`. + The reference AS also exposes a stable owner-entry page at `GET /owner/login`. It behaves as a small reference-only owner access hub: @@ -202,12 +208,12 @@ multilingual behavior. Operators can switch profiles without changing the public API: -- `PDPP_EMBEDDING_PROFILE_ID=minilm` — compact English-biased default. -- `PDPP_EMBEDDING_PROFILE_ID=multilingual-minilm` — multilingual MiniLM profile +- `PDPP_EMBEDDING_PROFILE_ID=minilm` - compact English-biased default. +- `PDPP_EMBEDDING_PROFILE_ID=multilingual-minilm` - multilingual MiniLM profile suitable for Italian-language data. -- `PDPP_EMBEDDING_MODEL_ID=...` — override the Hugging Face model ID. -- `PDPP_EMBEDDING_CACHE_DIR=...` — override the local model cache. -- `PDPP_SEMANTIC_EMBEDDING_BACKEND=stub|local|disabled` — force a backend mode. +- `PDPP_EMBEDDING_MODEL_ID=...` - override the Hugging Face model ID. +- `PDPP_EMBEDDING_CACHE_DIR=...` - override the local model cache. +- `PDPP_SEMANTIC_EMBEDDING_BACKEND=stub|local|disabled` - force a backend mode. Changing the profile/model/dtype/dimensions/metric invalidates existing semantic vectors. The reference reports `index_state: "stale"` or `"building"` @@ -270,28 +276,28 @@ The reference ships a minimal local-only owner-auth placeholder for the current Environment variables: -- `PDPP_OWNER_PASSWORD` — if set, the current owner/operator browser surfaces below require a valid owner session. If unset, the server keeps its current open local-dev behavior. -- `PDPP_OWNER_SUBJECT_ID` — optional. Defaults to `owner_local`. When placeholder auth is enabled, this value is the owner subject id used for every approved grant and device authorization; any `subject_id` submitted from a form or JSON body is ignored. +- `PDPP_OWNER_PASSWORD` - if set, the current owner/operator browser surfaces below require a valid owner session. If unset, the server keeps its current open local-dev behavior. +- `PDPP_OWNER_SUBJECT_ID` - optional. Defaults to `owner_local`. When placeholder auth is enabled, this value is the owner subject id used for every approved grant and device authorization; any `subject_id` submitted from a form or JSON body is ignored. Routes gated by the placeholder (when enabled): -- `GET /consent`, `POST /consent/approve`, `POST /consent/deny` +- `GET /consent`, `POST /consent/review`, `POST /consent/approve`, `POST /consent/deny` - `GET /device`, `POST /device/approve`, `POST /device/deny` - clean owner-console routes (via the composed console origin) -- every reference-only `_ref` read (`GET /_ref/*`) and mutation (`POST/PUT /_ref/*`). When `PDPP_OWNER_PASSWORD` is unset, `_ref` routes preserve the open local-dev behavior. When set, callers must present an owner session — the dashboard already forwards the `pdpp_owner_session` cookie, and CLI callers can pass the same value via `PDPP_OWNER_SESSION_COOKIE`. +- every reference-only `_ref` read (`GET /_ref/*`) and mutation (`POST/PUT /_ref/*`). When `PDPP_OWNER_PASSWORD` is unset, `_ref` routes preserve the open local-dev behavior. When set, callers must present an owner session - the dashboard already forwards the `pdpp_owner_session` cookie, and CLI callers can pass the same value via `PDPP_OWNER_SESSION_COOKIE`. Stable owner-entry routes: -- `GET /owner/login` — owner access page (supports a safe same-origin `return_to` query parameter). When placeholder auth is disabled it renders an honest disabled-state landing page; when enabled it renders either the sign-in form or a signed-in landing page. -- `POST /owner/login` — when placeholder auth is enabled, submits the owner password; on success sets a signed HTTP-only session cookie (`pdpp_owner_session`, 7 day lifetime by default, configurable with `PDPP_OWNER_SESSION_TTL_SECONDS`, `SameSite=Lax`, `Secure` when served over HTTPS) and redirects to `return_to` -- `POST /owner/logout` — clears the session cookie when present +- `GET /owner/login` - owner access page (supports a safe same-origin `return_to` query parameter). When placeholder auth is disabled it renders an honest disabled-state landing page; when enabled it renders either the sign-in form or a signed-in landing page. +- `POST /owner/login` - when placeholder auth is enabled, submits the owner password; on success sets a signed HTTP-only session cookie (`pdpp_owner_session`, 7 day lifetime by default, configurable with `PDPP_OWNER_SESSION_TTL_SECONDS`, `SameSite=Lax`, `Secure` when served over HTTPS) and redirects to `return_to` +- `POST /owner/logout` - clears the session cookie when present Unauthenticated HTML requests to the protected routes redirect to `/owner/login?return_to=...`; non-HTML callers receive an honest `401` with error code `owner_session_required`. The placeholder is intentionally narrow: - no user table, no external IdP, no multi-user auth -- stateless HMAC-signed session cookie — rotating `PDPP_OWNER_PASSWORD` invalidates existing sessions +- stateless HMAC-signed session cookie - rotating `PDPP_OWNER_PASSWORD` invalidates existing sessions - public protocol surfaces (`/oauth/par`, `/oauth/register`, `/oauth/token`, `/v1/*`, `/.well-known/*`) are **not** gated - the placeholder is still not a durable owner-auth story; it is only the current reference-local browser/session gate @@ -307,8 +313,8 @@ This hosted-UI layer is **reference-only** implementation support. It is **not** The reference now supports two deliberate local hosting modes: -- `direct` — AS on `:7662`, RS on `:7663`; best for protocol debugging, conformance-style testing, CLI, and agents -- `composed` — one browser-facing origin (default `http://localhost:3002`) proxying the internal AS/RS; best for the dashboard, owner flows, and demos +- `direct` - AS on `:7662`, RS on `:7663`; best for protocol debugging, conformance-style testing, CLI, and agents +- `composed` - one browser-facing origin (default `http://localhost:3002`) proxying the internal AS/RS; best for the dashboard, owner flows, and demos The shared topology inputs are: @@ -414,8 +420,8 @@ pnpm docker:reference:quick Open `http://localhost:${PDPP_WEB_PORT:-3002}` for the browser-facing reference origin. The Compose stack runs: -- `reference` — one AS/RS process, AS on `:7662`, RS on `:7663` -- `web` — the Next app on container `:3000`, mapped to host `${PDPP_WEB_PORT:-3002}` by default, +- `reference` - one AS/RS process, AS on `:7662`, RS on `:7663` +- `web` - the Next app on container `:3000`, mapped to host `${PDPP_WEB_PORT:-3002}` by default, proxying the AS/RS in composed mode To test the owner-present n.eko interaction-streaming backend, use the @@ -642,16 +648,14 @@ pnpm --dir reference-implementation example-client Defaults: `PORT=7674`, `AS_URL=http://localhost:7662`, `RS_URL=http://localhost:7663`. -The example supports both approval modes honestly: +The example uses the two-phase approval flow: -- when the reference server runs without `PDPP_OWNER_PASSWORD`, the example - uses the reference-local JSON shortcut at `POST /consent/approve` and - captures the token inline +- the example calls `POST /consent/review`, inspects the exact approval artifact, + and then calls `POST /consent/approve` with its revision - when `PDPP_OWNER_PASSWORD` is set, the inline shortcut is refused by the - reference server. The example surfaces that honestly, links out to the - hosted `/consent` page, and lets you paste the issued token back + reference server. The example links out to the hosted `/consent` page. -The example is a third-party client illustration — it is **not** a full +The example is a third-party client illustration - it is **not** a full generic OAuth authorization-code redirect client. It has no PKCE, no `/callback`, and no code exchange. It only exercises the endpoints the reference currently advertises. diff --git a/reference-implementation/cli/commands/auth.ts b/reference-implementation/cli/commands/auth.ts index 293ed1029..c1935fd48 100644 --- a/reference-implementation/cli/commands/auth.ts +++ b/reference-implementation/cli/commands/auth.ts @@ -1,6 +1,7 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 +import { basicIntrospectionAuthorization } from "../../server/introspection-http.ts"; import type { CliFlags } from "../lib/args.ts"; import { parseArgs } from "../lib/args.ts"; import { resolveAsUrl } from "../lib/common.ts"; @@ -45,19 +46,37 @@ async function runAuthIntrospect(flags: CliFlags): Promise<void> { throw new PdppUsageError("Missing required flag: --token"); } + const introspectionCredentials = resolveIntrospectionCallerCredentials(); const authSurface = await resolveAuthSurface(flags, { requireIntrospectionEndpoint: true, }); const { body } = await fetchJson(`${authSurface.introspectionEndpoint}`, { body: JSON.stringify({ token }), - headers: { "Content-Type": "application/json" }, + headers: { + Authorization: basicIntrospectionAuthorization(introspectionCredentials), + "Content-Type": "application/json", + }, method: "POST", }); writeData(body, resolveFormat(flags, "json", "json")); } +function resolveIntrospectionCallerCredentials(): { clientId: string; clientSecret: string } { + // Introspection credentials authenticate the CLI as a confidential resource + // server caller. Keep the secret out of argv, shell history, and process + // listings by accepting it through the environment only. + const clientId = process.env.PDPP_RS_INTROSPECTION_CLIENT_ID; + const clientSecret = process.env.PDPP_RS_INTROSPECTION_CLIENT_SECRET; + if (!(clientId && clientSecret)) { + throw new PdppUsageError( + "Missing introspection caller credentials: set PDPP_RS_INTROSPECTION_CLIENT_ID and PDPP_RS_INTROSPECTION_CLIENT_SECRET" + ); + } + return { clientId, clientSecret }; +} + // One device-flow token-endpoint poll attempt: returns true when a token // was obtained and written (caller should stop polling), false to keep // polling (authorization_pending), or throws to abort the flow. Extracted diff --git a/reference-implementation/docs/generated/query-cookbook.md b/reference-implementation/docs/generated/query-cookbook.md index 7bb331975..08259c74c 100644 --- a/reference-implementation/docs/generated/query-cookbook.md +++ b/reference-implementation/docs/generated/query-cookbook.md @@ -1,6 +1,6 @@ # PDPP query cookbook -All examples below target the public record-query surface at `/v1/streams/...`. Tokens are Bearer access tokens bound to a PDPP grant. Core spec §8 (Resource Server Interface) is authoritative for query syntax — the canonical `filter[<field>]` / `filter[<field>][op]` shapes, declaration-driven `query.range_filters` and `query.expand`, and the `limit_clamped` warning. This cookbook shows the smallest correct call for each shape; where it is terser than §8, §8 governs. +All examples below target the public record-query surface at `/v1/streams/...`. Tokens are Bearer access tokens bound to a PDPP grant. Core spec §8 (Resource Server Interface) is authoritative for query syntax - the canonical `filter[<field>]` / `filter[<field>][op]` shapes, declaration-driven `query.range_filters` and `query.expand`, and the `limit_clamped` warning. This cookbook shows the smallest correct call for each shape; where it is terser than §8, §8 governs. ## Discovery (one shot) @@ -82,9 +82,9 @@ GET /v1/streams/top_artists/records?view=basic ## Logical cursor pagination -Records are sorted by `(cursor_field, primary_key)`. Null cursor values sort after present values. Cursors are opaque — clients must not parse or construct them. Cursors are direction-bound: follow a page cursor with the same `order` value that produced it. To change direction, restart pagination without a cursor; the reference rejects order-mismatched cursors as `invalid_cursor`. +Records are sorted by `(cursor_field, primary_key)`. Null cursor values sort after present values. Cursors are opaque - clients must not parse or construct them. Cursors are direction-bound: follow a page cursor with the same `order` value that produced it. To change direction, restart pagination without a cursor; the reference rejects order-mismatched cursors as `invalid_cursor`. -`limit` defaults to 25 and is capped at 100. A request for more than 100 is clamped to 100 and returns a non-fatal `meta.warnings[]` entry with `code: "limit_clamped"`, not an error — page forward with the returned cursor rather than expecting a larger page. +`limit` defaults to 25 and is capped at 100. A request for more than 100 is clamped to 100 and returns a non-fatal `meta.warnings[]` entry with `code: "limit_clamped"`, not an error - page forward with the returned cursor rather than expecting a larger page. ```http GET /v1/streams/top_artists/records?order=asc&limit=50 @@ -112,7 +112,7 @@ GET /v1/streams/saved_tracks/records?expand[]=recently_played&expand_limit[recen ## Blob fetch -Records that include attachment-like bytes carry a `data.blob_ref` object. The reference RS decorates that object with a `fetch_url` (e.g., `/v1/blobs/<blob_id>`) which is the only supported byte-fetch path. There is no `/v1/attachments/<id>/content` (or similar) endpoint — discover bytes from the record's `blob_ref.fetch_url` rather than constructing attachment-specific content URLs. +Records that include attachment-like bytes carry a `data.blob_ref` object. The reference RS decorates that object with a `fetch_url` (e.g., `/v1/blobs/<blob_id>`) which is the only supported byte-fetch path. There is no `/v1/attachments/<id>/content` (or similar) endpoint - discover bytes from the record's `blob_ref.fetch_url` rather than constructing attachment-specific content URLs. ```http GET /v1/blobs/<blob_id> @@ -125,8 +125,10 @@ Authorized only if the caller holds a grant that includes a record referencing t 1. Register a client: `POST /oauth/register` (DCR initial access token required). 2. Start a grant request: `POST /oauth/par` with `authorization_details[0].type = https://pdpp.dev/data-access`. -3. Approve via the hosted consent page or `POST /consent/approve` with `request_uri` + subject id. -4. In the current thin reference flow, `POST /consent/approve` returns `{ grant_id, token, grant }` directly; there is no follow-on `/oauth/token` exchange for third-party client connect yet. +3. Review the request with `POST /consent/review` and inspect the exact `approval_review` artifact and `approval_review_revision`. +4. Approve with `POST /consent/approve` using `request_uri` and `approval_review_revision`. Do not submit stream or field choices again. +5. For a finalized batch review, also send `confirm_reviewed_decision` with the approval revision. +6. In the current thin reference flow, `POST /consent/approve` returns `{ grant_id, token, grant }` directly; there is no follow-on `/oauth/token` exchange for third-party client connect yet. ## Owner device flow @@ -136,13 +138,13 @@ Authorized only if the caller holds a grant that includes a record referencing t ## Error codes (spec §8) -- `400 invalid_request` — malformed query shape (unknown param, bad filter shape, nested path). -- `400 unknown_field` — `fields=` references a field outside the stream schema. -- `400 invalid_expand` — expansion requests an undeclared or non-`has_many` relation. -- `400 invalid_cursor` — cursor token malformed. -- `403 field_not_granted` — filter targets a field outside the grant projection. -- `403 grant_stream_not_allowed` — stream not in grant. -- `403 insufficient_scope` — expansion requests a stream not in the grant. -- `404 not_found` — stream or record not found. -- `404 blob_not_found` — `blob_id` is unknown or stale. -- `410 cursor_expired` — `changes_since` cursor too old; full re-sync required. +- `400 invalid_request` - malformed query shape (unknown param, bad filter shape, nested path). +- `400 unknown_field` - `fields=` references a field outside the stream schema. +- `400 invalid_expand` - expansion requests an undeclared or non-`has_many` relation. +- `400 invalid_cursor` - cursor token malformed. +- `403 field_not_granted` - filter targets a field outside the grant projection. +- `403 grant_stream_not_allowed` - stream not in grant. +- `403 insufficient_scope` - expansion requests a stream not in the grant. +- `404 not_found` - stream or record not found. +- `404 blob_not_found` - `blob_id` is unknown or stale. +- `410 cursor_expired` - `changes_since` cursor too old; full re-sync required. diff --git a/reference-implementation/docs/generated/reference-ref-routes.md b/reference-implementation/docs/generated/reference-ref-routes.md index 2664b93d4..aa0adfc00 100644 --- a/reference-implementation/docs/generated/reference-ref-routes.md +++ b/reference-implementation/docs/generated/reference-ref-routes.md @@ -37,6 +37,7 @@ Generated from `packages/reference-contract/src/reference/`. Reference-designate | **GET** | `/_ref/connector-instances/{connectorInstanceId}` | `refGetConnectorInstance` | Compatibility alias for reading one configured connector instance behind an owner-facing connection. | | **PATCH** | `/_ref/connections/{connectorInstanceId}` | `refSetConnectionDisplayName` | Owner-authenticated mutation of the owner-meaningful `display_name` carried on the public read contract. Operator-only surface; grant-authorized tokens SHALL NOT reach this route. | | **GET** | `/_ref/approvals` | `refListApprovals` | List pending approvals across provider-connect consents and owner-device flows. | +| **GET** | `/_ref/approvals/{approvalId}` | `refGetApproval` | Get one pending approval review by opaque approval_id. The reference projection excludes device-flow credentials and raw persisted request payloads. | | **POST** | `/_ref/device-exporters/enrollment-codes` | `refCreateDeviceExporterEnrollmentCode` | Create a short-lived local device exporter enrollment code for an owner-approved connector binding. | | **POST** | `/_ref/device-exporters/enroll` | `refExchangeDeviceExporterEnrollmentCode` | Exchange a one-time enrollment code for a device-scoped local exporter credential. | | **GET** | `/_ref/device-exporters` | `refListDeviceExporters` | List enrolled local device exporters and their source-instance diagnostics. | @@ -656,6 +657,23 @@ List pending approvals across provider-connect consents and owner-device flows. - `404` — Not found - `409` — Conflict (e.g. run_already_active) +## refGetApproval + +`GET /_ref/approvals/{approvalId}` + +Get one pending approval review by opaque approval_id. The reference projection excludes device-flow credentials and raw persisted request payloads. + +### Path parameters + +- `approvalId` — string + +### Responses + +- `200` — JSON body +- `400` — Invalid request +- `404` — Not found +- `409` — Conflict (e.g. run_already_active) + ## refCreateDeviceExporterEnrollmentCode `POST /_ref/device-exporters/enrollment-codes` diff --git a/reference-implementation/docs/generated/reference-routes.md b/reference-implementation/docs/generated/reference-routes.md index 86fe2b510..b56a094dc 100644 --- a/reference-implementation/docs/generated/reference-routes.md +++ b/reference-implementation/docs/generated/reference-routes.md @@ -7,24 +7,25 @@ Generated from `packages/reference-contract/src/public/`. Do not edit by hand. | **GET** | `/` | `getRsDiscoveryIndex` | Unauthenticated cold-start pointer at the resource server root. Names the well-known endpoint, the `/v1/schema` capability discovery surface, the core query base, and the running reference revision so a probe learns the next hop without trial-and-error. | | **GET** | `/` | `getAsDiscoveryIndex` | Unauthenticated cold-start pointer at the authorization server root. Names the AS well-known endpoint and the running reference revision so a probe learns the next hop without trial-and-error. | | **GET** | `/.well-known/oauth-authorization-server` | `getAuthorizationServerMetadata` | Return RFC 8414 authorization-server metadata with the reference provider-connect capability extensions. | -| **GET** | `/.well-known/oauth-protected-resource` | `getProtectedResourceMetadata` | Return RFC 9728 protected-resource metadata advertising the PDPP query base, owner-self-export, advisory `pdpp_agent_discovery` / `pdpp_owner_agent_onboarding` when safely configured, and capabilities such as `client_event_subscriptions`. | +| **GET** | `/.well-known/oauth-protected-resource` | `getProtectedResourceMetadata` | Return RFC 9728 protected-resource metadata advertising the optional provider-native `pdpp_source_declaration_uri`, the PDPP query base, owner-self-export, advisory `pdpp_agent_discovery` / `pdpp_owner_agent_onboarding` when safely configured, and capabilities such as `client_event_subscriptions`. | | **GET** | `/.well-known/oauth-protected-resource/mcp` | `getMcpProtectedResourceMetadata` | Return RFC 9728 protected-resource metadata for the hosted MCP endpoint. | | **POST** | `/oauth/register` | `registerDynamicClient` | Register a public client through the reference dynamic client registration profile. | | **POST** | `/oauth/par` | `createPushedAuthorizationRequest` | Stage a PDPP data-access request and receive a pending-consent request_uri plus authorization URL. | +| **POST** | `/consent/review` | `reviewConsent` | Finalize a consent review before approval. | | **POST** | `/consent/approve` | `approveConsent` | Approve a pending data-access request through the JSON consent surface used by tests and automation. | | **POST** | `/consent/exchange` | `exchangeConsentCode` | Redeem a short-lived single-use consent exchange code from the hosted HTML consent flow for the client token. | | **POST** | `/oauth/device_authorization` | `startOwnerDeviceAuthorization` | Start the owner device flow used for owner-self-export and dashboard bootstrap. | | **POST** | `/oauth/token` | `exchangeOwnerDeviceToken` | Exchange an OAuth device code, authorization code, or refresh token for a bearer token. | -| **POST** | `/introspect` | `introspectToken` | Inspect token activity and, for active client tokens, the bound grant projection. | +| **POST** | `/introspect` | `introspectToken` | Inspect token activity for an authenticated confidential resource server. | | **POST** | `/grants/{grantId}/revoke` | `revokeGrant` | Revoke a grant and all tokens minted from it. | | **GET** | `/v1/connectors` | `listConnectors` | List connector or source boundaries visible under the bearer token, with stream summaries and coarse capability hints. | | **GET** | `/v1/schema` | `getSchema` | Return the caller-visible source/stream capability graph. Use `view=compact` and optional `stream=<name>` for a token-efficient agent discovery step; omitted `view` returns the full schema, query declarations, field capabilities, expand capabilities, and freshness. | -| **GET** | `/v1/streams` | `listStreams` | List streams available under the current grant or owner scope. Returns stream-level totals only; for per-field filter capabilities (exact, range operators, aggregation) call `GET /v1/schema` first and consult `field_capabilities` per stream before issuing `filter[...]` queries on `/v1/streams/{stream}/records`. Multi-connection deployments emit one entry per (stream, connection_id); each entry carries `connection_id` and a `display_name` so callers can attribute and disambiguate. | -| **GET** | `/v1/streams/{stream}` | `getStreamMetadata` | Return stream metadata including declared query capabilities and advisory freshness. For per-field filter capabilities on this stream (exact, range operators, aggregation), prefer `GET /v1/schema` first and read `field_capabilities` rather than guessing `filter[...]` shapes against the records endpoint. Pass `connection_id` (or the deprecated `connector_instance_id` alias) to restrict to a single connection; omitted, the response aggregates across the connections the grant authorizes. | -| **GET** | `/v1/streams/{stream}/records` | `listRecords` | List records in a stream under grant enforcement. Supports logical-cursor pagination, exact and declared range filters, declared one-hop expansion, and changes_since. Per-field filter operators, sortable fields, expandable relations, projection, search modes, and count support are advertised by `GET /v1/schema` (`field_capabilities`, `expand_capabilities`); consult it before issuing `filter[...]`, `expand[]`, or `fields=` shapes to avoid 400 errors. Pass `connection_id` to restrict to one connection; the deprecated `connector_instance_id` alias is accepted for compatibility but new clients SHOULD use `connection_id`. | -| **GET** | `/v1/streams/{stream}/aggregate` | `aggregateStream` | Compute a single-stream grant-safe aggregation. Supports count, numeric sum, numeric/date min/max, exact count_distinct, scalar grouped counts (`group_by`), calendar time-bucket counts (`group_by_time`+`granularity`, optional `time_zone` defaulting to UTC), and existing exact/range filters over declared fields. Exactly one grouping dimension per call: `group_by` XOR `group_by_time`. Grouped responses include `other_count` (sum of counts for groups/buckets beyond `limit`) so callers can detect truncation without a second round trip. | +| **GET** | `/v1/streams` | `listStreams` | List streams available under the current grant or owner scope. Returns stream-level totals only. Owner-token current-capability callers can consult `GET /v1/schema` for per-field filter capabilities; client-token v0.1 reads reject `filter[...]`. Multi-connection deployments emit one entry per (stream, connection_id); each entry carries `connection_id` and a `display_name` so callers can attribute and disambiguate. | +| **GET** | `/v1/streams/{stream}` | `getStreamMetadata` | Return stream metadata including declared query capabilities and advisory freshness. Owner-token current-capability callers can consult `GET /v1/schema` for per-field filter capabilities; client-token v0.1 metadata does not advertise typed filter capabilities and client reads reject `filter[...]`. Pass `connection_id` (or the deprecated `connector_instance_id` alias) to restrict to a single connection; omitted, the response aggregates across the connections the grant authorizes. | +| **GET** | `/v1/streams/{stream}/records` | `listRecords` | List records in a stream under grant enforcement. Supports logical-cursor pagination, declared one-hop expansion, and changes_since. Client-token v0.1 reads reject exact and range `filter[...]` parameters before consulting current source metadata; owner-token current-capability reads MAY use declared filters. Per-field query capabilities are advertised by `GET /v1/schema`; consult it before issuing supported query shapes. Pass `connection_id` to restrict to one connection; the deprecated `connector_instance_id` alias is accepted for compatibility but new clients SHOULD use `connection_id`. | +| **GET** | `/v1/streams/{stream}/aggregate` | `aggregateStream` | Compute a single-stream grant-safe aggregation. Supports count, numeric sum, numeric/date min/max, exact count_distinct, scalar grouped counts (`group_by`), calendar time-bucket counts (`group_by_time`+`granularity`, optional `time_zone` defaulting to UTC), and owner-token current-capability exact/range filters over declared fields. Client-token v0.1 reads reject `filter[...]`. Exactly one grouping dimension per call: `group_by` XOR `group_by_time`. Grouped responses include `other_count` (sum of counts for groups/buckets beyond `limit`) so callers can detect truncation without a second round trip. | | **GET** | `/v1/streams/{stream}/records/{id}` | `getRecord` | Fetch a single record by primary key under grant enforcement, with optional declared one-hop expansion. Expandable relations and the per-relation `expand_limit` ceiling are advertised by `GET /v1/schema` (`expand_capabilities`); requesting an unadvertised relation is rejected rather than silently ignored. When the identifier resolves to more than one connection under the caller's grant and `connection_id` is omitted, returns a typed `ambiguous_connection` (409) error with `available_connections` and retry guidance instead of silently picking one. The deprecated `connector_instance_id` alias is accepted for compatibility but new clients SHOULD use `connection_id`. | -| **GET** | `/v1/search` | `searchRecordsLexical` | Optional lexical retrieval extension: search records across authorized streams by text. Search modes, per-mode cursor support, and field-level `lexical_search`/`semantic_search` capabilities are advertised by `GET /v1/schema`; `filter[...]` operators applied to a single named stream must come from that stream's `field_capabilities`. Hits carry `connection_id` for attribution; the deprecated `connector_instance_id` alias is emitted alongside for compatibility but new clients SHOULD read `connection_id`. | +| **GET** | `/v1/search` | `searchRecordsLexical` | Optional lexical retrieval extension: search records across authorized streams by text. Search modes, per-mode cursor support, and field-level `lexical_search`/`semantic_search` capabilities are advertised by `GET /v1/schema`. Client-token v0.1 reads reject `filter[...]`; owner-token current-capability reads MAY use declared filters. Hits carry `connection_id` for attribution; the deprecated `connector_instance_id` alias is emitted alongside for compatibility but new clients SHOULD read `connection_id`. | | **GET** | `/v1/search/semantic` | `searchRecordsSemantic` | Experimental optional extension: semantic retrieval across authorized streams by text. See the semantic-retrieval capability spec. Unstable in v1. Per-stream semantic capability and pagination support are advertised by `GET /v1/schema` and the `capabilities.semantic_retrieval` block in protected-resource metadata; consult them before relying on cursors or filters. Hits carry `connection_id` for attribution; the deprecated `connector_instance_id` alias is emitted for compatibility only. | | **GET** | `/v1/search/hybrid` | `searchRecordsHybrid` | Experimental optional extension: hybrid retrieval blending lexical and semantic recall under one grant-safe result list. See the hybrid-retrieval capability spec. Hybrid does NOT support cursor pagination on this reference; check `pdpp_discovery_hints.hybrid_pagination_supported` in the protected-resource metadata and, when it is `false` or absent, fall back to `GET /v1/search` (lexical) which supports `cursor`. | | **POST** | `/v1/blobs` | `uploadBlob` | Upload connector/runtime-owned blob bytes for a bound record. | @@ -44,7 +45,7 @@ Unauthenticated cold-start pointer at the resource server root. Names the well-k ### Responses -- `200` — JSON body +- `200` - JSON body ## getAsDiscoveryIndex @@ -54,7 +55,7 @@ Unauthenticated cold-start pointer at the authorization server root. Names the A ### Responses -- `200` — JSON body +- `200` - JSON body ## getAuthorizationServerMetadata @@ -64,17 +65,17 @@ Return RFC 8414 authorization-server metadata with the reference provider-connec ### Responses -- `200` — JSON body +- `200` - JSON body ## getProtectedResourceMetadata `GET /.well-known/oauth-protected-resource` -Return RFC 9728 protected-resource metadata advertising the PDPP query base, owner-self-export, advisory `pdpp_agent_discovery` / `pdpp_owner_agent_onboarding` when safely configured, and capabilities such as `client_event_subscriptions`. +Return RFC 9728 protected-resource metadata advertising the optional provider-native `pdpp_source_declaration_uri`, the PDPP query base, owner-self-export, advisory `pdpp_agent_discovery` / `pdpp_owner_agent_onboarding` when safely configured, and capabilities such as `client_event_subscriptions`. ### Responses -- `200` — JSON body +- `200` - JSON body ## getMcpProtectedResourceMetadata @@ -84,7 +85,7 @@ Return RFC 9728 protected-resource metadata for the hosted MCP endpoint. ### Responses -- `200` — JSON body +- `200` - JSON body ## registerDynamicClient @@ -95,23 +96,23 @@ Register a public client through the reference dynamic client registration profi ### Request body `application/json` -- `application_type` — string -- `client_name` — string -- `client_uri` — string · format: uri -- `grant_types` — array -- `logo_uri` — string · format: uri -- `policy_uri` — string · format: uri -- `redirect_uris` — array -- `response_types` — array -- `token_endpoint_auth_method` — enum `none` -- `tos_uri` — string · format: uri +- `application_type` - string +- `client_name` - string +- `client_uri` - string · format: uri +- `grant_types` - array +- `logo_uri` - string · format: uri +- `policy_uri` - string · format: uri +- `redirect_uris` - array +- `response_types` - array +- `token_endpoint_auth_method` - enum `none` +- `tos_uri` - string · format: uri ### Responses -- `201` — Client registered -- `400` — Invalid client metadata -- `401` — Missing or invalid initial access token -- `404` — Dynamic client registration is disabled +- `201` - Client registered +- `400` - Invalid client metadata +- `401` - Missing or invalid initial access token +- `404` - Dynamic client registration is disabled ## createPushedAuthorizationRequest @@ -122,16 +123,60 @@ Stage a PDPP data-access request and receive a pending-consent request_uri plus ### Request body `application/json` -- `authorization_details` (required) — array -- `client_display` — object -- `client_id` (required) — string -- `scenario_id` — string +- `authorization_details` (required) - array +- `client_display` - object +- `client_id` (required) - string +- `scenario_id` - string ### Responses -- `201` — Pending consent request created -- `400` — Invalid request -- `403` — Request rejected because the resolved grant contract is invalid +- `201` - Pending consent request created +- `400` - Invalid request +- `403` - Request rejected because the resolved grant contract is invalid + +## reviewConsent + +`POST /consent/review` + +Finalize a consent review before approval. + +### Request body + +`application/json` + +Alternative 1: +- `approval_id` - string +- `ai_training_consented` - any +- `request_uri` - string +- `subject_id` - string + + Required alternatives: +- `request_uri` +- `approval_id` + +Alternative 2: +- `approval_id` - string +- `approved_source_indexes` - any +- `confirm_approve_all` - any +- `request_uri` - string +- `source_narrowing` - object +- `subject_id` - string + + Required alternatives: +- `request_uri` +- `approval_id` + + At least one required: +- `approved_source_indexes` +- `confirm_approve_all` +- `source_narrowing` + +### Responses + +- `200` - Approval review finalized +- `400` - Invalid request +- `403` - Grant is malformed or no longer valid +- `404` - Pending consent request not found ## approveConsent @@ -142,18 +187,23 @@ Approve a pending data-access request through the JSON consent surface used by t ### Request body `application/json` -- `ai_training_consented` — boolean -- `approved_source_indexes` — any -- `confirm_approve_all` — any -- `request_uri` (required) — string -- `subject_id` — string + +Alternative 1: +- `approval_review_revision` (required) - string +- `request_uri` (required) - string + +Alternative 2: +- `approval_review_revision` (required) - string +- `confirm_reviewed_decision` (required) - any +- `request_uri` (required) - string ### Responses -- `200` — Grant approved and client token issued -- `400` — Invalid request -- `403` — Grant is malformed or no longer valid -- `404` — Pending consent request not found +- `200` - Grant approved and client token issued +- `400` - Invalid request +- `403` - Grant is malformed or no longer valid +- `404` - Pending consent request not found +- `409` - Pending consent approval conflict ## exchangeConsentCode @@ -164,14 +214,14 @@ Redeem a short-lived single-use consent exchange code from the hosted HTML conse ### Request body `application/json` -- `code` (required) — string +- `code` (required) - string ### Responses -- `200` — Exchange code redeemed and client token issued -- `400` — Invalid request -- `404` — Unknown exchange code -- `410` — Exchange code expired or already redeemed +- `200` - Exchange code redeemed and client token issued +- `400` - Invalid request +- `404` - Unknown exchange code +- `410` - Exchange code expired or already redeemed ## startOwnerDeviceAuthorization @@ -182,12 +232,12 @@ Start the owner device flow used for owner-self-export and dashboard bootstrap. ### Request body `application/x-www-form-urlencoded` -- `client_id` (required) — string +- `client_id` (required) - string ### Responses -- `200` — JSON body -- `400` — OAuth request rejected +- `200` - JSON body +- `400` - OAuth request rejected ## exchangeOwnerDeviceToken @@ -199,27 +249,45 @@ Exchange an OAuth device code, authorization code, or refresh token for a bearer `application/x-www-form-urlencoded` +Alternative 1: +- `client_id` (required) - string +- `device_code` (required) - string +- `grant_type` (required) - const `urn:ietf:params:oauth:grant-type:device_code` + +Alternative 2: +- `client_id` (required) - string +- `code` (required) - string +- `code_verifier` (required) - string +- `grant_type` (required) - const `authorization_code` +- `redirect_uri` (required) - string · format: uri + +Alternative 3: +- `client_id` (required) - string +- `grant_type` (required) - const `refresh_token` +- `refresh_token` (required) - string + ### Responses -- `200` — JSON body -- `400` — OAuth request rejected -- `500` — Server error while exchanging the device code +- `200` - JSON body +- `400` - OAuth request rejected +- `500` - Server error while exchanging the device code ## introspectToken `POST /introspect` -Inspect token activity and, for active client tokens, the bound grant projection. +Inspect token activity for an authenticated confidential resource server. ### Request body `application/x-www-form-urlencoded` -- `token` (required) — string +- `token` (required) - string ### Responses -- `200` — JSON body -- `400` — Missing token parameter +- `200` - JSON body +- `400` - Missing token parameter +- `401` - Confidential resource-server authentication failed ## revokeGrant @@ -229,12 +297,12 @@ Revoke a grant and all tokens minted from it. ### Path parameters -- `grantId` — string +- `grantId` - string ### Responses -- `200` — JSON body -- `403` — Grant is malformed or no longer valid +- `200` - JSON body +- `403` - Grant is malformed or no longer valid ## listConnectors @@ -244,11 +312,11 @@ List connector or source boundaries visible under the bearer token, with stream ### Responses -- `200` — JSON body -- `400` — Invalid request -- `401` — Missing or invalid access token -- `403` — Grant does not permit this request -- `404` — Stream or record not found +- `200` - JSON body +- `400` - Invalid request +- `401` - Missing or invalid access token +- `403` - Grant does not permit this request +- `404` - Stream or record not found ## getSchema @@ -258,132 +326,132 @@ Return the caller-visible source/stream capability graph. Use `view=compact` and ### Query parameters -- `connector_id` — string · Optional owner-polyfill source hint for runtimes that expose multiple connector templates. -- `stream` — string · When used with `view=compact`, narrows the schema document to connectors that contribute this stream. -- `view` — string · Set `view=compact` to return the token-efficient schema projection. Omitted or any other value returns the full schema body. +- `connector_id` - string · Optional owner-polyfill source hint for runtimes that expose multiple connector templates. +- `stream` - string · When used with `view=compact`, narrows the schema document to connectors that contribute this stream. +- `view` - string · Set `view=compact` to return the token-efficient schema projection. Omitted or any other value returns the full schema body. ### Responses -- `200` — JSON body -- `400` — Invalid request -- `401` — Missing or invalid access token -- `403` — Grant does not permit this request -- `404` — Stream or record not found +- `200` - JSON body +- `400` - Invalid request +- `401` - Missing or invalid access token +- `403` - Grant does not permit this request +- `404` - Stream or record not found ## listStreams `GET /v1/streams` -List streams available under the current grant or owner scope. Returns stream-level totals only; for per-field filter capabilities (exact, range operators, aggregation) call `GET /v1/schema` first and consult `field_capabilities` per stream before issuing `filter[...]` queries on `/v1/streams/{stream}/records`. Multi-connection deployments emit one entry per (stream, connection_id); each entry carries `connection_id` and a `display_name` so callers can attribute and disambiguate. +List streams available under the current grant or owner scope. Returns stream-level totals only. Owner-token current-capability callers can consult `GET /v1/schema` for per-field filter capabilities; client-token v0.1 reads reject `filter[...]`. Multi-connection deployments emit one entry per (stream, connection_id); each entry carries `connection_id` and a `display_name` so callers can attribute and disambiguate. ### Query parameters -- `connection_id` — string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. -- `connector_id` — string -- `connector_instance_id` — string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. -- `subject_id` — string +- `connection_id` - string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. +- `connector_id` - string +- `connector_instance_id` - string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. +- `subject_id` - string ### Responses -- `200` — JSON body -- `400` — Invalid request -- `401` — Missing or invalid access token -- `403` — Grant does not permit this request -- `404` — Stream or record not found +- `200` - JSON body +- `400` - Invalid request +- `401` - Missing or invalid access token +- `403` - Grant does not permit this request +- `404` - Stream or record not found ## getStreamMetadata `GET /v1/streams/{stream}` -Return stream metadata including declared query capabilities and advisory freshness. For per-field filter capabilities on this stream (exact, range operators, aggregation), prefer `GET /v1/schema` first and read `field_capabilities` rather than guessing `filter[...]` shapes against the records endpoint. Pass `connection_id` (or the deprecated `connector_instance_id` alias) to restrict to a single connection; omitted, the response aggregates across the connections the grant authorizes. +Return stream metadata including declared query capabilities and advisory freshness. Owner-token current-capability callers can consult `GET /v1/schema` for per-field filter capabilities; client-token v0.1 metadata does not advertise typed filter capabilities and client reads reject `filter[...]`. Pass `connection_id` (or the deprecated `connector_instance_id` alias) to restrict to a single connection; omitted, the response aggregates across the connections the grant authorizes. ### Query parameters -- `connection_id` — string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. -- `connector_id` — string -- `connector_instance_id` — string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. -- `subject_id` — string +- `connection_id` - string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. +- `connector_id` - string +- `connector_instance_id` - string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. +- `subject_id` - string ### Path parameters -- `stream` — string +- `stream` - string ### Responses -- `200` — JSON body -- `400` — Invalid request -- `401` — Missing or invalid access token -- `403` — Grant does not permit this request -- `404` — Stream or record not found +- `200` - JSON body +- `400` - Invalid request +- `401` - Missing or invalid access token +- `403` - Grant does not permit this request +- `404` - Stream or record not found ## listRecords `GET /v1/streams/{stream}/records` -List records in a stream under grant enforcement. Supports logical-cursor pagination, exact and declared range filters, declared one-hop expansion, and changes_since. Per-field filter operators, sortable fields, expandable relations, projection, search modes, and count support are advertised by `GET /v1/schema` (`field_capabilities`, `expand_capabilities`); consult it before issuing `filter[...]`, `expand[]`, or `fields=` shapes to avoid 400 errors. Pass `connection_id` to restrict to one connection; the deprecated `connector_instance_id` alias is accepted for compatibility but new clients SHOULD use `connection_id`. +List records in a stream under grant enforcement. Supports logical-cursor pagination, declared one-hop expansion, and changes_since. Client-token v0.1 reads reject exact and range `filter[...]` parameters before consulting current source metadata; owner-token current-capability reads MAY use declared filters. Per-field query capabilities are advertised by `GET /v1/schema`; consult it before issuing supported query shapes. Pass `connection_id` to restrict to one connection; the deprecated `connector_instance_id` alias is accepted for compatibility but new clients SHOULD use `connection_id`. ### Query parameters -- `changes_since` — string · `beginning` for initial sync, or an opaque changes-since token from next_changes_since. Distinct from list-page cursors. -- `connection_id` — string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. -- `connector_id` — string -- `connector_instance_id` — string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. -- `cursor` — string · Opaque logical pagination cursor. Encodes (cursor_field, primary_key) position. -- `expand` — array -- `expand_limit` — object -- `fields` — string -- `filter` — object · Per-field filter map. Exact: `filter[field]=value`. Range: `filter[field][op]=value` where `op` is one of the declared `field_capabilities.range_filter.operators` from `GET /v1/schema`. -- `limit` — integer · min: 1 · max: 100 -- `order` — enum `asc | desc` -- `subject_id` — string -- `view` — string -- `window` — enum `none | exact` +- `changes_since` - string · `beginning` for initial sync, or an opaque changes-since token from next_changes_since. Distinct from list-page cursors. +- `connection_id` - string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. +- `connector_id` - string +- `connector_instance_id` - string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. +- `cursor` - string · Opaque logical pagination cursor. Encodes (cursor_field, primary_key) position. +- `expand` - array +- `expand_limit` - object +- `fields` - string +- `filter` - object · Owner-token current-capability filter map only. Client-token v0.1 reads reject exact `filter[field]=value` and range `filter[field][op]=value` before consulting current source metadata. +- `limit` - integer · min: 1 · max: 100 +- `order` - enum `asc | desc` +- `subject_id` - string +- `view` - string +- `window` - enum `none | exact` ### Path parameters -- `stream` — string +- `stream` - string ### Responses -- `200` — JSON body -- `400` — Invalid request -- `401` — Missing or invalid access token -- `403` — Grant does not permit this request -- `404` — Stream or record not found -- `410` — Cursor expired +- `200` - JSON body +- `400` - Invalid request +- `401` - Missing or invalid access token +- `403` - Grant does not permit this request +- `404` - Stream or record not found +- `410` - Cursor expired ## aggregateStream `GET /v1/streams/{stream}/aggregate` -Compute a single-stream grant-safe aggregation. Supports count, numeric sum, numeric/date min/max, exact count_distinct, scalar grouped counts (`group_by`), calendar time-bucket counts (`group_by_time`+`granularity`, optional `time_zone` defaulting to UTC), and existing exact/range filters over declared fields. Exactly one grouping dimension per call: `group_by` XOR `group_by_time`. Grouped responses include `other_count` (sum of counts for groups/buckets beyond `limit`) so callers can detect truncation without a second round trip. +Compute a single-stream grant-safe aggregation. Supports count, numeric sum, numeric/date min/max, exact count_distinct, scalar grouped counts (`group_by`), calendar time-bucket counts (`group_by_time`+`granularity`, optional `time_zone` defaulting to UTC), and owner-token current-capability exact/range filters over declared fields. Client-token v0.1 reads reject `filter[...]`. Exactly one grouping dimension per call: `group_by` XOR `group_by_time`. Grouped responses include `other_count` (sum of counts for groups/buckets beyond `limit`) so callers can detect truncation without a second round trip. ### Query parameters -- `connection_id` — string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. -- `connector_id` — string -- `connector_instance_id` — string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. -- `field` — string -- `filter` — object -- `granularity` — enum `minute | hour | day | week | month | quarter | year` -- `group_by` — string -- `group_by_time` — string · Group counts into calendar time buckets over a declared date/date-time field. Mutually exclusive with `group_by`. Requires `granularity`. -- `limit` — integer · min: 1 · max: 100 -- `metric` — enum `count | sum | min | max | count_distinct` -- `subject_id` — string -- `time_zone` — string · IANA time zone used to compute `group_by_time` bucket boundaries. Defaults to `UTC`; the response echoes the effective zone. +- `connection_id` - string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. +- `connector_id` - string +- `connector_instance_id` - string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. +- `field` - string +- `filter` - object +- `granularity` - enum `minute | hour | day | week | month | quarter | year` +- `group_by` - string +- `group_by_time` - string · Group counts into calendar time buckets over a declared date/date-time field. Mutually exclusive with `group_by`. Requires `granularity`. +- `limit` - integer · min: 1 · max: 100 +- `metric` - enum `count | sum | min | max | count_distinct` +- `subject_id` - string +- `time_zone` - string · IANA time zone used to compute `group_by_time` bucket boundaries. Defaults to `UTC`; the response echoes the effective zone. ### Path parameters -- `stream` — string +- `stream` - string ### Responses -- `200` — JSON body -- `400` — Invalid request -- `401` — Missing or invalid access token -- `403` — Grant does not permit this request -- `404` — Stream or record not found +- `200` - JSON body +- `400` - Invalid request +- `401` - Missing or invalid access token +- `403` - Grant does not permit this request +- `404` - Stream or record not found ## getRecord @@ -393,50 +461,50 @@ Fetch a single record by primary key under grant enforcement, with optional decl ### Query parameters -- `connection_id` — string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. -- `connector_id` — string -- `connector_instance_id` — string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. -- `expand` — array -- `expand_limit` — object -- `subject_id` — string +- `connection_id` - string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. +- `connector_id` - string +- `connector_instance_id` - string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. +- `expand` - array +- `expand_limit` - object +- `subject_id` - string ### Path parameters -- `id` — string -- `stream` — string +- `id` - string +- `stream` - string ### Responses -- `200` — JSON body -- `400` — Invalid request -- `401` — Missing or invalid access token -- `403` — Grant does not permit this request -- `404` — Stream or record not found -- `409` — Identifier resolves to more than one connection under the caller's grant. Retry with the `connection_id` listed in `error.available_connections`. +- `200` - JSON body +- `400` - Invalid request +- `401` - Missing or invalid access token +- `403` - Grant does not permit this request +- `404` - Stream or record not found +- `409` - Identifier resolves to more than one connection under the caller's grant. Retry with the `connection_id` listed in `error.available_connections`. ## searchRecordsLexical `GET /v1/search` -Optional lexical retrieval extension: search records across authorized streams by text. Search modes, per-mode cursor support, and field-level `lexical_search`/`semantic_search` capabilities are advertised by `GET /v1/schema`; `filter[...]` operators applied to a single named stream must come from that stream's `field_capabilities`. Hits carry `connection_id` for attribution; the deprecated `connector_instance_id` alias is emitted alongside for compatibility but new clients SHOULD read `connection_id`. +Optional lexical retrieval extension: search records across authorized streams by text. Search modes, per-mode cursor support, and field-level `lexical_search`/`semantic_search` capabilities are advertised by `GET /v1/schema`. Client-token v0.1 reads reject `filter[...]`; owner-token current-capability reads MAY use declared filters. Hits carry `connection_id` for attribution; the deprecated `connector_instance_id` alias is emitted alongside for compatibility but new clients SHOULD read `connection_id`. ### Query parameters -- `connection_id` — string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. -- `connector_instance_id` — string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. -- `cursor` — string · Opaque logical pagination cursor. Encodes (cursor_field, primary_key) position. -- `filter` — object -- `limit` — integer · min: 1 · max: 100 -- `q` — string -- `streams` — any +- `connection_id` - string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. +- `connector_instance_id` - string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. +- `cursor` - string · Opaque logical pagination cursor. Encodes (cursor_field, primary_key) position. +- `filter` - object +- `limit` - integer · min: 1 · max: 100 +- `q` - string +- `streams` - any ### Responses -- `200` — JSON body -- `400` — Invalid request (e.g. unsupported v1 query parameter, missing q) -- `401` — Missing or invalid access token -- `403` — Grant does not permit a named stream (client tokens only) -- `410` — Cursor expired or refers to an unknown snapshot +- `200` - JSON body +- `400` - Invalid request (e.g. unsupported v1 query parameter, missing q) +- `401` - Missing or invalid access token +- `403` - Grant does not permit a named stream (client tokens only) +- `410` - Cursor expired or refers to an unknown snapshot ## searchRecordsSemantic @@ -446,21 +514,21 @@ Experimental optional extension: semantic retrieval across authorized streams by ### Query parameters -- `connection_id` — string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. -- `connector_instance_id` — string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. -- `cursor` — string · Opaque logical pagination cursor. Encodes (cursor_field, primary_key) position. -- `filter` — object -- `limit` — integer · min: 1 · max: 100 -- `q` — string -- `streams` — any +- `connection_id` - string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. +- `connector_instance_id` - string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. +- `cursor` - string · Opaque logical pagination cursor. Encodes (cursor_field, primary_key) position. +- `filter` - object +- `limit` - integer · min: 1 · max: 100 +- `q` - string +- `streams` - any ### Responses -- `200` — JSON body -- `400` — Invalid request (e.g. unsupported v1 query parameter, missing q) -- `401` — Missing or invalid access token -- `403` — Grant does not permit a named stream (client tokens only) -- `410` — Cursor expired or refers to an unknown snapshot +- `200` - JSON body +- `400` - Invalid request (e.g. unsupported v1 query parameter, missing q) +- `401` - Missing or invalid access token +- `403` - Grant does not permit a named stream (client tokens only) +- `410` - Cursor expired or refers to an unknown snapshot ## searchRecordsHybrid @@ -470,20 +538,20 @@ Experimental optional extension: hybrid retrieval blending lexical and semantic ### Query parameters -- `connection_id` — string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. -- `connector_instance_id` — string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. -- `filter` — object -- `limit` — integer · min: 1 · max: 100 -- `q` — string -- `streams` — any +- `connection_id` - string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. +- `connector_instance_id` - string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. +- `filter` - object +- `limit` - integer · min: 1 · max: 100 +- `q` - string +- `streams` - any ### Responses -- `200` — JSON body -- `400` — Invalid request (e.g. unsupported v1 query parameter, missing q, cursor parameter) -- `401` — Missing or invalid access token -- `403` — Grant does not permit a named stream (client tokens only) -- `404` — Hybrid retrieval not advertised on this server +- `200` - JSON body +- `400` - Invalid request (e.g. unsupported v1 query parameter, missing q, cursor parameter) +- `401` - Missing or invalid access token +- `403` - Grant does not permit a named stream (client tokens only) +- `404` - Hybrid retrieval not advertised on this server ## uploadBlob @@ -493,9 +561,9 @@ Upload connector/runtime-owned blob bytes for a bound record. ### Query parameters -- `connector_id` — string -- `record_key` — string -- `stream` — string +- `connector_id` - string +- `record_key` - string +- `stream` - string ### Request body @@ -503,11 +571,11 @@ Upload connector/runtime-owned blob bytes for a bound record. ### Responses -- `200` — Canonical content-addressed blob identity for the uploaded bytes -- `400` — Invalid upload request -- `401` — Missing or invalid access token -- `403` — Owner/runtime authority required -- `404` — Unknown connector or stream +- `200` - Canonical content-addressed blob identity for the uploaded bytes +- `400` - Invalid upload request +- `401` - Missing or invalid access token +- `403` - Owner/runtime authority required +- `404` - Unknown connector or stream ## getBlob @@ -517,21 +585,21 @@ Fetch blob bytes authorized by the caller having discovered the referencing reco ### Query parameters -- `connection_id` — string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. -- `connector_instance_id` — string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. +- `connection_id` - string · Canonical public identifier for a connection (one owner-configured account/device/profile). Prefer this over the deprecated `connector_instance_id` alias. +- `connector_instance_id` - string · Deprecated wire alias for `connection_id`. Emitted alongside `connection_id` during the migration window. New clients SHOULD ignore this field and read `connection_id` instead. ### Path parameters -- `blob_id` — string +- `blob_id` - string ### Responses -- `200` — Blob bytes -- `400` — Invalid request -- `401` — Missing or invalid access token -- `403` — Grant does not permit this request -- `404` — Stream or record not found -- `409` — Identifier resolves to more than one connection under the caller's grant. Retry with the `connection_id` listed in `error.available_connections`. +- `200` - Blob bytes +- `400` - Invalid request +- `401` - Missing or invalid access token +- `403` - Grant does not permit this request +- `404` - Stream or record not found +- `409` - Identifier resolves to more than one connection under the caller's grant. Retry with the `connection_id` listed in `error.available_connections`. ## createEventSubscription @@ -542,15 +610,15 @@ Create an event subscription for the bearer's explicit authority (`client_grant` ### Request body `application/json` -- `callback_url` (required) — string · format: uri · HTTPS endpoint that will receive CloudEvents 1.0 structured-mode JSON POST requests signed with Standard Webhooks headers. `http://localhost` is accepted for development. -- `filters` — object +- `callback_url` (required) - string · format: uri · HTTPS endpoint that will receive CloudEvents 1.0 structured-mode JSON POST requests signed with Standard Webhooks headers. `http://localhost` is accepted for development. +- `filters` - object ### Responses -- `201` — Subscription created. The `secret` field is the Standard Webhooks signing key (`whsec_<base64>`) and is returned only on creation. -- `400` — Invalid request (callback URL malformed, filters not in grant, etc.) -- `401` — Bearer token missing or invalid -- `403` — Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. +- `201` - Subscription created. The `secret` field is the Standard Webhooks signing key (`whsec_<base64>`) and is returned only on creation. +- `400` - Invalid request (callback URL malformed, filters not in grant, etc.) +- `401` - Bearer token missing or invalid +- `403` - Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. ## listEventSubscriptions @@ -560,9 +628,9 @@ List all non-deleted event subscriptions for the bearer's authority tuple (`auth ### Responses -- `200` — JSON body -- `401` — Bearer token missing or invalid -- `403` — Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. +- `200` - JSON body +- `401` - Bearer token missing or invalid +- `403` - Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. ## getEventSubscription @@ -572,14 +640,14 @@ Get a single event subscription owned by the bearer. ### Path parameters -- `subscription_id` — string +- `subscription_id` - string ### Responses -- `200` — JSON body -- `401` — Bearer token missing or invalid -- `403` — Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. -- `404` — Subscription not found or not owned by the bearer +- `200` - JSON body +- `401` - Bearer token missing or invalid +- `403` - Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. +- `404` - Subscription not found or not owned by the bearer ## updateEventSubscription @@ -589,22 +657,22 @@ Update an event subscription. Toggle `enabled` to disable or re-enable delivery. ### Path parameters -- `subscription_id` — string +- `subscription_id` - string ### Request body `application/json` -- `enabled` — boolean · Set to `false` to disable delivery; `true` to re-enable a `disabled` or `disabled_failure` subscription. Cannot re-enable a `disabled_revoked` subscription. -- `rotate_secret` — boolean · Generate a new `whsec_*` signing secret. The new secret is returned in the response body. The old secret is immediately invalid. +- `enabled` - boolean · Set to `false` to disable delivery; `true` to re-enable a `disabled` or `disabled_failure` subscription. Cannot re-enable a `disabled_revoked` subscription. +- `rotate_secret` - boolean · Generate a new `whsec_*` signing secret. The new secret is returned in the response body. The old secret is immediately invalid. ### Responses -- `200` — Updated subscription. `secret` is only present when `rotate_secret` was `true`. -- `400` — Invalid update (e.g. re-enabling a revoked subscription) -- `401` — Bearer token missing or invalid -- `403` — Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. -- `404` — Subscription not found or not owned by the bearer -- `409` — State conflict (e.g. re-enabling a `disabled_revoked` subscription) +- `200` - Updated subscription. `secret` is only present when `rotate_secret` was `true`. +- `400` - Invalid update (e.g. re-enabling a revoked subscription) +- `401` - Bearer token missing or invalid +- `403` - Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. +- `404` - Subscription not found or not owned by the bearer +- `409` - State conflict (e.g. re-enabling a `disabled_revoked` subscription) ## deleteEventSubscription @@ -614,14 +682,14 @@ Delete an event subscription. Queued undelivered events are dropped. Idempotent ### Path parameters -- `subscription_id` — string +- `subscription_id` - string ### Responses -- `204` — Subscription deleted. -- `401` — Bearer token missing or invalid -- `403` — Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. -- `404` — Subscription not found or not owned by the bearer +- `204` - Subscription deleted. +- `401` - Bearer token missing or invalid +- `403` - Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. +- `404` - Subscription not found or not owned by the bearer ## sendTestEvent @@ -631,12 +699,12 @@ Enqueue a `pdpp.subscription.test` event for asynchronous delivery to the subscr ### Path parameters -- `subscription_id` — string +- `subscription_id` - string ### Responses -- `202` — Test event accepted for delivery. -- `401` — Bearer token missing or invalid -- `403` — Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. -- `404` — Subscription not found or not owned by the bearer -- `409` — Subscription is not in a state that accepts test events (must be `active` or `pending_verification`) +- `202` - Test event accepted for delivery. +- `401` - Bearer token missing or invalid +- `403` - Bearer token is authenticated but is neither a `client_grant` authority for an active grant nor a registered `trusted_owner_agent` authority; unregistered owner bearers are rejected. +- `404` - Subscription not found or not owned by the bearer +- `409` - Subscription is not in a state that accepts test events (must be `active` or `pending_verification`) diff --git a/reference-implementation/examples/third-party-app/README.md b/reference-implementation/examples/third-party-app/README.md index 9c6ce8ed9..b11a5195f 100644 --- a/reference-implementation/examples/third-party-app/README.md +++ b/reference-implementation/examples/third-party-app/README.md @@ -15,11 +15,11 @@ the reference AS currently advertises: 1. `POST /oauth/register` — public-client self-registration 2. `POST /oauth/par` — PAR request staging -3. Owner approval at `GET /consent?request_uri=...` - - and, when owner-auth is disabled, a reference-local inline JSON shortcut - at `POST /consent/approve` -4. `POST /introspect` — RFC 7662-style introspection (optional) -5. `GET {rs}/v1/streams` / `GET {rs}/v1/streams/:stream/records` — RS reads +3. Review and owner approval: + - `POST /consent/review` returns the exact approval artifact and its revision + - `POST /consent/approve` accepts only the request URI and that revision + - `GET /consent?request_uri=...` provides the hosted approval page +4. `GET {rs}/v1/streams` / `GET {rs}/v1/streams/:stream/records` — RS reads ## Run @@ -57,18 +57,19 @@ the form can be submitted as-is once that manifest is registered. ## Approval modes -- When `PDPP_OWNER_PASSWORD` is **unset** on the reference server, this app - can use the inline JSON approval shortcut (`POST /consent/approve` with - `Accept: application/json`) and capture the issued token directly. -- When `PDPP_OWNER_PASSWORD` is **set**, the inline shortcut is rejected by - the reference server. This app surfaces that honestly, prompts you to open - the hosted `/consent` page, and then lets you paste the token back in. +- The inline JSON path performs a review first. It receives the exact artifact, + keeps its revision, and submits that revision for final approval. The final + request does not submit stream or field choices again. +- When `PDPP_OWNER_PASSWORD` is **set**, the inline path is rejected by the + reference server. This app prompts you to open the hosted `/consent` page. ## What this app proves — and does not prove -This app proves that the current reference flow — register → PAR → -owner approval → token → RS query — works end to end against a local +This app proves that the current reference flow, from registration through PAR, +review, owner approval, token, and RS query, works end to end against a local reference stack. It does **not** prove a full generic third-party authorization-code redirect -profile. That remains out of scope for the current reference. +profile. It also does not expose RFC 7662 token introspection: in the reference +runtime, introspection is a confidential AS-to-RS boundary and requires +operator-configured caller credentials. diff --git a/reference-implementation/examples/third-party-app/lib/flow.ts b/reference-implementation/examples/third-party-app/lib/flow.ts index 5e594815e..b7e2b8e2b 100644 --- a/reference-implementation/examples/third-party-app/lib/flow.ts +++ b/reference-implementation/examples/third-party-app/lib/flow.ts @@ -8,9 +8,9 @@ * * POST /oauth/register (public-client self-registration) * POST /oauth/par (PAR request staging) - * POST /consent/approve (reference-local inline approval shortcut) + * POST /consent/review (finalize and inspect the approval artifact) + * POST /consent/approve (approve the reviewed artifact) * POST /consent/deny (reference-local inline denial shortcut) - * POST /introspect (RFC 7662-style introspection) * GET {rs}/v1/streams (owner/client RS read) * * This is **not** a generic OAuth authorization-code redirect client. It is a @@ -28,10 +28,6 @@ type SourceKind = "connector" | "provider_native"; type RegistrationMetadata = { client_name: string; token_endpoint_auth_method: "none" } & JsonObject; type RegisteredClient = JsonObject & { client_id: string }; type ParResponse = JsonObject & { request_uri: string; authorization_url?: string }; -interface IntrospectionResponse { - active: boolean; - [key: string]: unknown; -} interface StreamsResponse { streams?: unknown[]; [key: string]: unknown; @@ -214,10 +210,55 @@ export function buildHostedApprovalUrl({ asUrl, requestUri }: { asUrl: string; r } /** - * Reference-local inline approval shortcut. This calls the hosted consent - * endpoint directly with a JSON body and asks for a JSON response. Only usable - * when owner-auth placeholder is disabled; when it is enabled, the AS will - * respond with a redirect / 401 and this helper surfaces that honestly. + * Finalize the exact approval artifact before approval. The returned artifact + * is the server's reviewable projection. The revision binds the final approval + * to those facts, so the caller must not submit selection choices again. + */ +export async function reviewInline({ + asUrl, + requestUri, + subjectId, +}: { + asUrl: string; + requestUri: string; + subjectId: string; +}): Promise<{ requestUri: string; review: unknown; revision: string }> { + const response = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const body = await readJsonOrText(response); + if (!response.ok || body.kind !== "json") { + const err = new RequestError(describeFailure(body.value, `approval review failed (${response.status})`)); + err.status = response.status; + if (response.status === 401 || response.status === 403) { + err.ownerAuthEnabled = true; + } + throw err; + } + const reviewBody = jsonObject(body.value, "approval review response"); + const revision = reviewBody.approval_review_revision; + if (typeof revision !== "string" || !revision) { + throw new Error("approval review returned without approval_review_revision"); + } + if ( + !reviewBody.approval_review || + typeof reviewBody.approval_review !== "object" || + Array.isArray(reviewBody.approval_review) + ) { + throw new Error("approval review returned without the exact approval artifact"); + } + const canonicalRequestUri = reviewBody.request_uri; + if (typeof canonicalRequestUri !== "string" || canonicalRequestUri !== requestUri) { + throw new Error("approval review returned a different canonical request_uri"); + } + return { requestUri: canonicalRequestUri, review: reviewBody.approval_review, revision }; +} + +/** + * Reference-local JSON approval flow. It reviews the exact artifact first, + * then submits only its revision for final approval. */ export async function approveInline({ asUrl, @@ -228,8 +269,9 @@ export async function approveInline({ requestUri: string; subjectId: string; }): Promise<{ token: string; grantId: string | null; grant: unknown }> { + const reviewed = await reviewInline({ asUrl, requestUri, subjectId }); const response = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId }), + body: JSON.stringify({ approval_review_revision: reviewed.revision, request_uri: reviewed.requestUri }), headers: { Accept: "application/json", "Content-Type": "application/json", @@ -309,25 +351,6 @@ export async function denyInline({ asUrl, requestUri }: { asUrl: string; request return { ok: true }; } -export async function introspectToken({ - asUrl, - token, -}: { - asUrl: string; - token: string; -}): Promise<IntrospectionResponse> { - const response = await fetch(`${asUrl}/introspect`, { - body: JSON.stringify({ token }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - const body = await readJsonOrText(response); - if (!response.ok || body.kind !== "json") { - throw new Error(describeFailure(body.value, `introspection failed (${response.status})`)); - } - return jsonObject(body.value, "introspection response") as IntrospectionResponse; -} - export async function queryStreams({ rsUrl, token }: { rsUrl: string; token: string }): Promise<StreamsResponse> { const response = await fetch(`${rsUrl}/v1/streams`, { headers: { Authorization: `Bearer ${token}` }, diff --git a/reference-implementation/examples/third-party-app/server.ts b/reference-implementation/examples/third-party-app/server.ts index ab03d66dd..38aedfa59 100644 --- a/reference-implementation/examples/third-party-app/server.ts +++ b/reference-implementation/examples/third-party-app/server.ts @@ -11,8 +11,8 @@ * - scoped to run against a local reference AS + RS (defaults: * AS http://localhost:7662, RS http://localhost:7663) * - uses only the existing public reference endpoints: - * POST /oauth/register, POST /oauth/par, POST /consent/approve (inline - * JSON shortcut), GET /consent (hosted approval page), POST /introspect, + * POST /oauth/register, POST /oauth/par, POST /consent/review, + * POST /consent/approve, GET /consent (hosted approval page), * GET {rs}/v1/streams, GET {rs}/v1/streams/:stream/records * * What this is NOT: @@ -69,9 +69,7 @@ interface QueryInfo { interface ExampleState { clientId: string; draft: Draft; - introspection: unknown | null; lastApprovalError: string | null; - lastIntrospectError: string | null; lastParError: string | null; lastQuery: QueryInfo | null; lastQueryError: string | null; @@ -103,7 +101,6 @@ import { buildHostedApprovalUrl, buildParRequest, denyInline, - introspectToken, queryStreamRecords, queryStreams, registerClient, @@ -164,9 +161,7 @@ export function buildDefaultDraft() { const state: ExampleState = { clientId: "", draft: buildDefaultDraft(), - introspection: null, lastApprovalError: null, - lastIntrospectError: null, lastParError: null, lastQuery: null, lastQueryError: null, @@ -318,11 +313,6 @@ function renderPage() { <div style="margin-top: 6px;"><strong>access_token:</strong> <code>${escapeHtml(state.tokenInfo.token)}</code></div> ${conditionalHtml(state.tokenInfo.grantId, `<div><strong>grant_id:</strong> <code>${escapeHtml(state.tokenInfo.grantId || "")}</code></div>`)} <details><summary>Issued grant snapshot</summary><pre>${escapeHtml(JSON.stringify(state.tokenInfo.grant || state.tokenInfo, null, 2))}</pre></details> - <form method="post" action="/introspect" class="actions"> - <button type="submit" class="secondary">Introspect token</button> - </form> - ${conditionalHtml(state.introspection, `<details open><summary>Introspection result</summary><pre>${escapeHtml(JSON.stringify(state.introspection, null, 2))}</pre></details>`)} - ${conditionalHtml(state.lastIntrospectError, `<div class="err">${escapeHtml(state.lastIntrospectError || "")}</div>`)} ` : '<div class="muted">No token yet. Approve the request above, or paste a token obtained from the hosted consent page.</div>' } @@ -471,28 +461,12 @@ app.post("/token/paste", (req: Request, res: Response) => { const pasted = state.draft.pastedToken; if (pasted) { state.tokenInfo = { grant: null, grantId: null, source: "pasted from hosted approval", token: pasted }; - state.introspection = null; - state.lastIntrospectError = null; } else { state.lastApprovalError = "No token pasted."; } res.redirect("/"); }); -app.post("/introspect", async (_req: Request, res: Response) => { - state.lastIntrospectError = null; - if (!state.tokenInfo?.token) { - state.lastIntrospectError = "No token to introspect."; - return res.redirect("/"); - } - try { - state.introspection = await introspectToken({ asUrl: AS_URL, token: state.tokenInfo.token }); - } catch (err) { - state.lastIntrospectError = errorMessage(err); - } - res.redirect("/"); -}); - app.post("/query/streams", async (_req: Request, res: Response) => { state.lastQueryError = null; if (!state.tokenInfo?.token) { @@ -538,8 +512,6 @@ app.post("/reset", (_req: Request, res: Response) => { state.tokenInfo = null; state.lastApprovalError = null; state.ownerAuthSuspected = false; - state.introspection = null; - state.lastIntrospectError = null; state.lastQuery = null; state.lastQueryError = null; res.redirect("/"); diff --git a/reference-implementation/lib/postgres-spine.ts b/reference-implementation/lib/postgres-spine.ts index b5df97a20..888b3d5ba 100644 --- a/reference-implementation/lib/postgres-spine.ts +++ b/reference-implementation/lib/postgres-spine.ts @@ -1209,6 +1209,18 @@ export function postgresEmitSpineEventWithClient( return appendPostgresSpineEventInTransaction(client, input); } +export async function postgresEmitSpineEventInTransaction( + client: PoolClient, + input: PostgresSpineEventInput = {} +): Promise<SpineEventRecord | null> { + const event = normalize(input); + const result = await client.query<SpineEventRow>(SPINE_INSERT_EVENT_SQL, spineInsertEventParams(event)); + if (isRunHistoryRelevantEventType(event.event_type)) { + await writePostgresRunHistoryForSpineEvent(client, toRunHistorySpineEvent(event, input.data)); + } + return hydrate(result.rows[0]); +} + export async function postgresListSpineEventsPage( kind: string, id: string, diff --git a/reference-implementation/manifests/northstar-hr.json b/reference-implementation/manifests/northstar-hr.json index 5ea72a37d..158854110 100644 --- a/reference-implementation/manifests/northstar-hr.json +++ b/reference-implementation/manifests/northstar-hr.json @@ -1,10 +1,158 @@ { - "provider_id": "northstar_hr", + "provider_id": "https://northstar.example/pdpp", "storage_binding": { "connector_id": "northstar_hr_native" }, "version": "0.1.0", "name": "Northstar HR", + "source_declaration": { + "protocol_version": "0.1.0", + "source": { + "kind": "provider_native", + "id": "https://northstar.example/pdpp" + }, + "declaration_version": "reference.native-config.northstar-hr.v1", + "publisher": { + "id": "https://pdpp.dev/reference-implementation" + }, + "display": { + "name": "Northstar HR" + }, + "streams": [ + { + "name": "pay_statements", + "semantics": "mutable_state", + "primary_key": ["statement_id"], + "cursor_field": "issued_at", + "consent_time_field": "pay_period_end", + "selection": { + "fields": true, + "resources": true + }, + "schema": { + "type": "object", + "required": [ + "statement_id", + "pay_period_start", + "pay_period_end", + "gross_pay", + "net_pay" + ], + "properties": { + "statement_id": { "type": "string" }, + "employer": { "type": "string" }, + "pay_period_start": { "type": "string", "format": "date" }, + "pay_period_end": { "type": "string", "format": "date" }, + "issued_at": { "type": "string", "format": "date-time" }, + "gross_pay": { "type": "number" }, + "net_pay": { "type": "number" }, + "currency": { "type": "string" }, + "employee_id": { "type": "string" } + } + }, + "views": [ + { + "id": "summary", + "label": "Pay statement summary", + "fields": [ + "employer", + "pay_period_start", + "pay_period_end", + "gross_pay", + "net_pay", + "currency" + ] + } + ] + }, + { + "name": "equity_grants", + "semantics": "mutable_state", + "primary_key": ["grant_id"], + "cursor_field": "granted_at", + "consent_time_field": "granted_at", + "selection": { + "fields": true, + "resources": true + }, + "schema": { + "type": "object", + "required": ["grant_id", "grant_type", "quantity", "granted_at"], + "properties": { + "grant_id": { "type": "string" }, + "employer": { "type": "string" }, + "grant_type": { "type": "string" }, + "quantity": { "type": "number" }, + "strike_price": { "type": "number" }, + "currency": { "type": "string" }, + "granted_at": { "type": "string", "format": "date-time" }, + "vesting_start_date": { "type": "string", "format": "date" }, + "vesting_end_date": { "type": "string", "format": "date" }, + "employee_id": { "type": "string" } + } + }, + "views": [ + { + "id": "summary", + "label": "Equity grant summary", + "fields": [ + "employer", + "grant_type", + "quantity", + "currency", + "granted_at", + "vesting_start_date", + "vesting_end_date" + ] + } + ] + }, + { + "name": "benefits_enrollments", + "semantics": "mutable_state", + "primary_key": ["enrollment_id"], + "cursor_field": "effective_date", + "consent_time_field": "effective_date", + "selection": { + "fields": true, + "resources": true + }, + "schema": { + "type": "object", + "required": [ + "enrollment_id", + "plan_name", + "coverage_level", + "effective_date" + ], + "properties": { + "enrollment_id": { "type": "string" }, + "employer": { "type": "string" }, + "plan_name": { "type": "string" }, + "coverage_level": { "type": "string" }, + "effective_date": { "type": "string", "format": "date" }, + "employee_cost_monthly": { "type": "number" }, + "currency": { "type": "string" }, + "employee_id": { "type": "string" } + } + }, + "views": [ + { + "id": "summary", + "label": "Benefits enrollment summary", + "fields": [ + "employer", + "plan_name", + "coverage_level", + "effective_date", + "employee_cost_monthly", + "currency" + ] + } + ] + } + ] + }, "streams": [ { "name": "pay_statements", diff --git a/reference-implementation/openapi/reference-full.openapi.json b/reference-implementation/openapi/reference-full.openapi.json index ec3177ec4..0620e058c 100644 --- a/reference-implementation/openapi/reference-full.openapi.json +++ b/reference-implementation/openapi/reference-full.openapi.json @@ -965,6 +965,11 @@ "pdpp_self_export_supported": { "type": "boolean" }, + "pdpp_source_declaration_uri": { + "format": "uri", + "pattern": "^(?!.*[\\p{Cc}\\s\\\\#])(?!.*%(?![0-9A-Fa-f]{2}))[Hh][Tt][Tt][Pp][Ss]:\\/\\/(?![^/?#]*@)(?:\\[[0-9A-Fa-f:.]+\\](?::\\d+)?|[^/?#\\s\\\\@:%]+(?::\\d+)?)(?:[/?][^\\s\\\\#]*)?$", + "type": "string" + }, "pdpp_token_kinds_supported": { "items": { "enum": [ @@ -1001,7 +1006,7 @@ } } }, - "summary": "Return RFC 9728 protected-resource metadata advertising the PDPP query base, owner-self-export, advisory `pdpp_agent_discovery` / `pdpp_owner_agent_onboarding` when safely configured, and capabilities such as `client_event_subscriptions`.", + "summary": "Return RFC 9728 protected-resource metadata advertising the optional provider-native `pdpp_source_declaration_uri`, the PDPP query base, owner-self-export, advisory `pdpp_agent_discovery` / `pdpp_owner_agent_onboarding` when safely configured, and capabilities such as `client_event_subscriptions`.", "tags": [ "metadata" ] @@ -1586,6 +1591,11 @@ "pdpp_self_export_supported": { "type": "boolean" }, + "pdpp_source_declaration_uri": { + "format": "uri", + "pattern": "^(?!.*[\\p{Cc}\\s\\\\#])(?!.*%(?![0-9A-Fa-f]{2}))[Hh][Tt][Tt][Pp][Ss]:\\/\\/(?![^/?#]*@)(?:\\[[0-9A-Fa-f:.]+\\](?::\\d+)?|[^/?#\\s\\\\@:%]+(?::\\d+)?)(?:[/?][^\\s\\\\#]*)?$", + "type": "string" + }, "pdpp_token_kinds_supported": { "items": { "enum": [ @@ -1722,6 +1732,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -1749,6 +1762,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -1776,6 +1792,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -2341,315 +2360,1957 @@ } } }, - "/consent/approve": { + "/consent/review": { "post": { - "operationId": "approveConsent", + "operationId": "reviewConsent", "parameters": [], "responses": { "200": { - "description": "Grant approved and client token issued", + "description": "Approval review finalized", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { - "grant": { - "additionalProperties": false, - "properties": { - "access_mode": { - "enum": [ - "single_use", - "continuous" - ], - "type": "string" - }, - "client": { + "approval_review": { + "oneOf": [ + { "additionalProperties": false, "properties": { - "client_display": { + "access_mode": { + "enum": [ + "continuous", + "single_use" + ], + "type": "string" + }, + "ai_training_consented": { + "type": [ + "boolean", + "null" + ] + }, + "client": { "additionalProperties": false, "properties": { - "logo_uri": { - "format": "uri", - "type": "string" + "client_display": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "logo_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "policy_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tos_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] }, - "name": { + "client_id": { "minLength": 1, "type": "string" }, - "policy_uri": { - "format": "uri", - "type": "string" - }, - "tos_uri": { - "format": "uri", - "type": "string" - }, - "uri": { - "format": "uri", + "registration_mode": { + "enum": [ + "dynamic", + "client_id_metadata_document", + "pre_registered_public" + ], "type": "string" } }, + "required": [ + "client_id", + "registration_mode" + ], "type": "object" }, - "client_id": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "client_id" - ], - "type": "object" - }, - "expires_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "grant_id": { - "minLength": 1, - "type": "string" - }, - "issued_at": { - "format": "date-time", - "type": "string" - }, - "purpose_code": { - "format": "uri", - "minLength": 1, - "type": "string" - }, - "purpose_description": { - "minLength": 1, - "type": "string" - }, - "retention": { - "additionalProperties": false, - "properties": { - "max_duration": { - "minLength": 1, - "type": "string" + "client_claims": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "commitments": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "commitments" + ], + "type": "object" + }, + { + "type": "null" + } + ] }, - "on_expiry": { - "enum": [ - "delete", - "anonymize" - ], - "type": "string" - } - }, - "required": [ - "max_duration", - "on_expiry" - ], - "type": "object" - }, - "selection_preset": { - "minLength": 1, - "type": "string" - }, - "source": { - "additionalProperties": false, - "properties": { - "id": { - "format": "uri", - "minLength": 1, - "type": "string" + "expires_at": { + "type": [ + "string", + "null" + ] }, - "kind": { - "enum": [ - "connector", - "provider_native" - ], - "type": "string" - } - }, - "required": [ - "kind", - "id" - ], - "type": "object" - }, - "source_declaration": { - "additionalProperties": false, - "properties": { - "version": { + "purpose_code": { "minLength": 1, "type": "string" - } - }, - "required": [ - "version" - ], - "type": "object" - }, - "streams": { - "items": { - "additionalProperties": false, - "properties": { - "fields": { - "items": { - "minLength": 1, - "type": "string" - }, - "minItems": 1, - "type": "array", - "uniqueItems": true - }, - "instance_ids": { - "items": { - "minLength": 1, - "type": "string" - }, - "minItems": 1, - "type": "array", - "uniqueItems": true - }, - "name": { - "minLength": 1, - "not": { - "const": "*" - }, - "type": "string" - }, - "resources": { - "items": { - "minLength": 1, - "type": "string" - }, - "minItems": 1, - "type": "array", - "uniqueItems": true - }, - "time_constraint": { + }, + "purpose_description": { + "type": [ + "string", + "null" + ] + }, + "resolved_streams": { + "items": { "additionalProperties": false, - "anyOf": [ - { - "required": [ - "since" - ] - }, - { - "required": [ - "until" - ] - } - ], "properties": { - "field": { - "minLength": 1, - "type": "string" + "fields": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true }, - "since": { - "format": "date-time", - "type": "string" + "instance_ids": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true }, - "until": { - "format": "date-time", + "name": { + "minLength": 1, + "not": { + "const": "*" + }, "type": "string" + }, + "resources": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "time_constraint": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "since" + ] + }, + { + "required": [ + "until" + ] + } + ], + "properties": { + "field": { + "minLength": 1, + "type": "string" + }, + "since": { + "format": "date-time", + "type": "string" + }, + "until": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "field" + ], + "type": "object" } }, "required": [ - "field" + "name", + "instance_ids", + "fields" ], "type": "object" - } + }, + "minItems": 1, + "type": "array", + "uniqueItems": true }, - "required": [ - "name", - "instance_ids", - "fields" - ], - "type": "object" - }, - "minItems": 1, - "type": "array", - "uniqueItems": true - }, - "subject": { - "additionalProperties": false, - "properties": { - "id": { - "minLength": 1, - "type": "string" + "retention": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "max_duration": { + "minLength": 1, + "type": "string" + }, + "on_expiry": { + "enum": [ + "delete", + "anonymize" + ], + "type": "string" + } + }, + "required": [ + "max_duration", + "on_expiry" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "selection_preset": { + "type": [ + "string", + "null" + ] + }, + "source": { + "additionalProperties": false, + "properties": { + "id": { + "format": "uri", + "minLength": 1, + "type": "string" + }, + "kind": { + "enum": [ + "connector", + "provider_native" + ], + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "type": "object" + }, + "source_declaration": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "digest": { + "minLength": 1, + "type": "string" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "digest", + "version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "digest": { + "minLength": 1, + "type": "string" + }, + "publisher_attribution": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "unverified" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "resource_authority": { + "additionalProperties": false, + "properties": { + "status": { + "const": "local_operator_provisioned" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "digest", + "publisher_attribution", + "resource_authority", + "version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "accepted_revision_reference": { + "minLength": 1, + "type": "string" + }, + "digest": { + "minLength": 1, + "type": "string" + }, + "publisher_attribution": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "unverified" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "resource_authority": { + "additionalProperties": false, + "properties": { + "authority_binding": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "verified" + } + }, + "required": [ + "authority_binding", + "status" + ], + "type": "object" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "accepted_revision_reference", + "digest", + "publisher_attribution", + "resource_authority", + "version" + ], + "type": "object" + } + ] + }, + "subject": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "version": { + "const": "reference.approval-review.v1" } }, "required": [ - "id" + "access_mode", + "ai_training_consented", + "client", + "client_claims", + "expires_at", + "purpose_code", + "purpose_description", + "resolved_streams", + "retention", + "selection_preset", + "source", + "source_declaration", + "subject", + "version" ], "type": "object" }, - "version": { - "const": "0.1.0", - "type": "string" - } - }, - "required": [ - "version", - "grant_id", - "issued_at", - "subject", - "client", - "source", - "source_declaration", - "purpose_code", - "access_mode", - "streams" - ], - "type": "object" - }, - "grant_id": { - "minLength": 1, - "type": "string" - }, - "token": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "grant_id", - "token", - "grant" - ], - "type": "object" - } - } - } - }, - "400": { - "description": "Invalid request", - "content": { - "application/json": { - "schema": { - "$id": "pdpp/common/PdppError", - "additionalProperties": false, - "properties": { - "error": { - "additionalProperties": false, - "properties": { - "available_connections": { - "items": { - "$id": "pdpp/common/ErrorAvailableConnection", - "additionalProperties": false, - "properties": { - "connection_id": { - "type": "string" - }, - "connector_id": { - "type": "string" - }, - "connector_key": { - "type": "string" + { + "additionalProperties": false, + "properties": { + "access_mode": { + "enum": [ + "continuous", + "single_use" + ], + "type": [ + "string", + "null" + ] + }, + "approved_source_indexes": { + "items": { + "minimum": 0, + "type": "integer" }, - "display_name": { - "type": [ - "string", - "null" - ] + "type": "array" + }, + "client": { + "additionalProperties": false, + "properties": { + "client_display": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "logo_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "policy_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tos_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "client_id": { + "minLength": 1, + "type": "string" + }, + "registration_mode": { + "enum": [ + "dynamic", + "client_id_metadata_document", + "pre_registered_public" + ], + "type": "string" + } + }, + "required": [ + "client_id", + "registration_mode" + ], + "type": "object" + }, + "expires_at": { + "type": [ + "string", + "null" + ] + }, + "parent_package_id": { + "type": [ + "string", + "null" + ] + }, + "source_narrowing": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "fields": { + "additionalProperties": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + "since": { + "additionalProperties": { + "minLength": 1, + "type": "string" + }, + "type": "object" + }, + "streams": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "propertyNames": { + "pattern": "^(0|[1-9][0-9]*)$" + }, + "type": "object" + }, + "sources": { + "items": { + "additionalProperties": false, + "properties": { + "access_mode": { + "enum": [ + "continuous", + "single_use" + ], + "type": "string" + }, + "client_claims": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "commitments": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "commitments" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "index": { + "minimum": 0, + "type": "integer" + }, + "purpose_code": { + "minLength": 1, + "type": "string" + }, + "purpose_description": { + "type": [ + "string", + "null" + ] + }, + "resolved_streams": { + "items": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "instance_ids": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "name": { + "minLength": 1, + "not": { + "const": "*" + }, + "type": "string" + }, + "resources": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "time_constraint": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "since" + ] + }, + { + "required": [ + "until" + ] + } + ], + "properties": { + "field": { + "minLength": 1, + "type": "string" + }, + "since": { + "format": "date-time", + "type": "string" + }, + "until": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "field" + ], + "type": "object" + } + }, + "required": [ + "name", + "instance_ids", + "fields" + ], + "type": "object" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "retention": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "max_duration": { + "minLength": 1, + "type": "string" + }, + "on_expiry": { + "enum": [ + "delete", + "anonymize" + ], + "type": "string" + } + }, + "required": [ + "max_duration", + "on_expiry" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "selection_preset": { + "type": [ + "string", + "null" + ] + }, + "source": { + "additionalProperties": false, + "properties": { + "id": { + "format": "uri", + "minLength": 1, + "type": "string" + }, + "kind": { + "enum": [ + "connector", + "provider_native" + ], + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "type": "object" + }, + "source_declaration": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "digest": { + "minLength": 1, + "type": "string" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "digest", + "version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "digest": { + "minLength": 1, + "type": "string" + }, + "publisher_attribution": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "unverified" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "resource_authority": { + "additionalProperties": false, + "properties": { + "status": { + "const": "local_operator_provisioned" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "digest", + "publisher_attribution", + "resource_authority", + "version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "accepted_revision_reference": { + "minLength": 1, + "type": "string" + }, + "digest": { + "minLength": 1, + "type": "string" + }, + "publisher_attribution": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "unverified" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "resource_authority": { + "additionalProperties": false, + "properties": { + "authority_binding": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "verified" + } + }, + "required": [ + "authority_binding", + "status" + ], + "type": "object" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "accepted_revision_reference", + "digest", + "publisher_attribution", + "resource_authority", + "version" + ], + "type": "object" + } + ] + } + }, + "required": [ + "access_mode", + "client_claims", + "index", + "purpose_code", + "purpose_description", + "resolved_streams", + "retention", + "selection_preset", + "source", + "source_declaration" + ], + "type": "object" + }, + "type": "array" + }, + "subject": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "version": { + "const": "reference.batch-approval-review.v1" + } + }, + "required": [ + "access_mode", + "approved_source_indexes", + "client", + "expires_at", + "parent_package_id", + "source_narrowing", + "sources", + "subject", + "version" + ], + "type": "object" + } + ] + }, + "approval_review_revision": { + "minLength": 1, + "type": "string" + }, + "batch": { + "type": "boolean" + }, + "request_uri": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "approval_review", + "approval_review_revision", + "batch", + "request_uri" + ], + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "Grant is malformed or no longer valid", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "Pending consent request not found", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + } + }, + "summary": "Finalize a consent review before approval.", + "tags": [ + "grants" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "additionalProperties": false, + "oneOf": [ + { + "required": [ + "request_uri" + ] + }, + { + "required": [ + "approval_id" + ] + } + ], + "properties": { + "approval_id": { + "minLength": 1, + "type": "string" + }, + "ai_training_consented": { + "oneOf": [ + { + "type": "boolean" + }, + { + "enum": [ + "true", + "false", + "1", + "0", + "on", + "off" + ], + "type": "string" + } + ] + }, + "request_uri": { + "minLength": 1, + "type": "string" + }, + "subject_id": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "approved_source_indexes" + ] + }, + { + "required": [ + "confirm_approve_all" + ] + }, + { + "required": [ + "source_narrowing" + ] + } + ], + "oneOf": [ + { + "required": [ + "request_uri" + ] + }, + { + "required": [ + "approval_id" + ] + } + ], + "properties": { + "approval_id": { + "minLength": 1, + "type": "string" + }, + "approved_source_indexes": { + "oneOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "pattern": "^[0-9]+$", + "type": "string" + }, + { + "items": { + "oneOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "pattern": "^[0-9]+$", + "type": "string" + } + ] + }, + "type": "array" + } + ] + }, + "confirm_approve_all": { + "oneOf": [ + { + "type": "boolean" + }, + { + "enum": [ + "true", + "1", + "on" + ], + "type": "string" + } + ] + }, + "request_uri": { + "minLength": 1, + "type": "string" + }, + "source_narrowing": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "fields": { + "additionalProperties": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + "since": { + "additionalProperties": { + "minLength": 1, + "type": "string" + }, + "type": "object" + }, + "streams": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "propertyNames": { + "pattern": "^(0|[1-9][0-9]*)$" + }, + "type": "object" + }, + "subject_id": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "required": true + } + } + }, + "/consent/approve": { + "post": { + "operationId": "approveConsent", + "parameters": [], + "responses": { + "200": { + "description": "Grant approved and client token issued", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "grant": { + "additionalProperties": false, + "properties": { + "access_mode": { + "enum": [ + "single_use", + "continuous" + ], + "type": "string" + }, + "client": { + "additionalProperties": false, + "properties": { + "client_display": { + "additionalProperties": false, + "properties": { + "logo_uri": { + "format": "uri", + "type": "string" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "policy_uri": { + "format": "uri", + "type": "string" + }, + "tos_uri": { + "format": "uri", + "type": "string" + }, + "uri": { + "format": "uri", + "type": "string" + } + }, + "type": "object" + }, + "client_id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "client_id" + ], + "type": "object" + }, + "expires_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "grant_id": { + "minLength": 1, + "type": "string" + }, + "issued_at": { + "format": "date-time", + "type": "string" + }, + "purpose_code": { + "format": "uri", + "minLength": 1, + "type": "string" + }, + "purpose_description": { + "minLength": 1, + "type": "string" + }, + "retention": { + "additionalProperties": false, + "properties": { + "max_duration": { + "minLength": 1, + "type": "string" + }, + "on_expiry": { + "enum": [ + "delete", + "anonymize" + ], + "type": "string" + } + }, + "required": [ + "max_duration", + "on_expiry" + ], + "type": "object" + }, + "selection_preset": { + "minLength": 1, + "type": "string" + }, + "source": { + "additionalProperties": false, + "properties": { + "id": { + "format": "uri", + "minLength": 1, + "type": "string" + }, + "kind": { + "enum": [ + "connector", + "provider_native" + ], + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "type": "object" + }, + "source_declaration": { + "additionalProperties": false, + "properties": { + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "version" + ], + "type": "object" + }, + "streams": { + "items": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "instance_ids": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "name": { + "minLength": 1, + "not": { + "const": "*" + }, + "type": "string" + }, + "resources": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "time_constraint": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "since" + ] + }, + { + "required": [ + "until" + ] + } + ], + "properties": { + "field": { + "minLength": 1, + "type": "string" + }, + "since": { + "format": "date-time", + "type": "string" + }, + "until": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "field" + ], + "type": "object" + } + }, + "required": [ + "name", + "instance_ids", + "fields" + ], + "type": "object" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "subject": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "version": { + "const": "0.1.0", + "type": "string" + } + }, + "required": [ + "version", + "grant_id", + "issued_at", + "subject", + "client", + "source", + "source_declaration", + "purpose_code", + "access_mode", + "streams" + ], + "type": "object" + }, + "grant_id": { + "minLength": 1, + "type": "string" + }, + "token": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "grant_id", + "token", + "grant" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "grant": { + "additionalProperties": false, + "properties": { + "child_grants": { + "items": { + "additionalProperties": false, + "properties": { + "grant_id": { + "minLength": 1, + "type": "string" + }, + "source": { + "additionalProperties": false, + "properties": { + "connection_id": { + "minLength": 1, + "type": "string" + }, + "id": { + "minLength": 1, + "type": "string" + }, + "kind": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "required": [ + "grant_id", + "source" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "grant_id": { + "minLength": 1, + "type": "string" + }, + "package": { + "const": true + }, + "package_id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "child_grants", + "grant_id", + "package", + "package_id" + ], + "type": "object" + }, + "package_id": { + "minLength": 1, + "type": "string" + }, + "token": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "grant", + "package_id", + "token" + ], + "type": "object" + } + ] + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "Grant is malformed or no longer valid", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] }, "label_status": { "enum": [ @@ -2708,8 +4369,8 @@ } } }, - "403": { - "description": "Grant is malformed or no longer valid", + "404": { + "description": "Pending consent request not found", "content": { "application/json": { "schema": { @@ -2796,8 +4457,8 @@ } } }, - "404": { - "description": "Pending consent request not found", + "409": { + "description": "Pending consent approval conflict", "content": { "application/json": { "schema": { @@ -2893,66 +4554,60 @@ "content": { "application/json": { "schema": { - "additionalProperties": false, - "properties": { - "ai_training_consented": { - "type": "boolean" - }, - "approved_source_indexes": { - "oneOf": [ - { - "minimum": 0, - "type": "integer" - }, - { - "pattern": "^[0-9]+$", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "approval_review_revision": { + "minLength": 1, "type": "string" }, - { - "items": { - "oneOf": [ - { - "minimum": 0, - "type": "integer" - }, - { - "pattern": "^[0-9]+$", - "type": "string" - } - ] - }, - "type": "array" + "request_uri": { + "minLength": 1, + "type": "string" } - ] + }, + "required": [ + "approval_review_revision", + "request_uri" + ], + "type": "object" }, - "confirm_approve_all": { - "oneOf": [ - { - "type": "boolean" + { + "additionalProperties": false, + "properties": { + "approval_review_revision": { + "minLength": 1, + "type": "string" }, - { - "enum": [ - "true", - "1", - "on" - ], + "confirm_reviewed_decision": { + "oneOf": [ + { + "type": "boolean" + }, + { + "enum": [ + "true", + "1", + "on" + ], + "type": "string" + } + ] + }, + "request_uri": { + "minLength": 1, "type": "string" } - ] - }, - "request_uri": { - "minLength": 1, - "type": "string" - }, - "subject_id": { - "minLength": 1, - "type": "string" + }, + "required": [ + "approval_review_revision", + "confirm_reviewed_decision", + "request_uri" + ], + "type": "object" } - }, - "required": [ - "request_uri" - ], - "type": "object" + ] } } }, @@ -3594,6 +5249,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -3712,6 +5370,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -3739,6 +5400,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -4118,41 +5782,129 @@ "streams" ], "type": "object" - }, - "grant_id": { - "minLength": 1, - "type": "string" - }, - "inactive_reason": { - "minLength": 1, - "type": "string" - }, - "pdpp_token_kind": { - "description": "Core defines \"owner\" and \"client\". Deployments MAY introduce additional token kinds in companion profiles (the reference emits \"mcp_package\"). A resource server that receives a pdpp_token_kind value it does not recognize MUST treat the token as unauthorized for all operations defined in Core.", - "type": "string" - }, - "scenario_id": { - "minLength": 1, - "type": "string" - }, - "subject_id": { - "type": "string" - }, - "trace_id": { - "minLength": 1, - "type": "string" + }, + "grant_id": { + "minLength": 1, + "type": "string" + }, + "inactive_reason": { + "minLength": 1, + "type": "string" + }, + "pdpp_token_kind": { + "description": "Core defines \"owner\" and \"client\". Deployments MAY introduce additional token kinds in companion profiles (the reference emits \"mcp_package\"). A resource server that receives a pdpp_token_kind value it does not recognize MUST treat the token as unauthorized for all operations defined in Core.", + "type": "string" + }, + "scenario_id": { + "minLength": 1, + "type": "string" + }, + "subject_id": { + "type": "string" + }, + "trace_id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "active" + ], + "type": "object" + } + } + } + }, + "400": { + "description": "Missing token parameter", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" } }, "required": [ - "active" + "error" ], "type": "object" } } } }, - "400": { - "description": "Missing token parameter", + "401": { + "description": "Confidential resource-server authentication failed", "content": { "application/json": { "schema": { @@ -4240,7 +5992,7 @@ } } }, - "summary": "Inspect token activity and, for active client tokens, the bound grant projection.", + "summary": "Inspect token activity for an authenticated confidential resource server.", "tags": [ "oauth" ], @@ -5282,8 +7034,6 @@ "required": [ "schema", "granted", - "exact_filter", - "range_filter", "lexical_search", "semantic_search", "aggregation" @@ -6312,7 +8062,7 @@ } } }, - "summary": "List streams available under the current grant or owner scope. Returns stream-level totals only; for per-field filter capabilities (exact, range operators, aggregation) call `GET /v1/schema` first and consult `field_capabilities` per stream before issuing `filter[...]` queries on `/v1/streams/{stream}/records`. Multi-connection deployments emit one entry per (stream, connection_id); each entry carries `connection_id` and a `display_name` so callers can attribute and disambiguate.", + "summary": "List streams available under the current grant or owner scope. Returns stream-level totals only. Owner-token current-capability callers can consult `GET /v1/schema` for per-field filter capabilities; client-token v0.1 reads reject `filter[...]`. Multi-connection deployments emit one entry per (stream, connection_id); each entry carries `connection_id` and a `display_name` so callers can attribute and disambiguate.", "tags": [ "records" ] @@ -6700,8 +8450,6 @@ "required": [ "schema", "granted", - "exact_filter", - "range_filter", "lexical_search", "semantic_search", "aggregation" @@ -7208,7 +8956,7 @@ } } }, - "summary": "Return stream metadata including declared query capabilities and advisory freshness. For per-field filter capabilities on this stream (exact, range operators, aggregation), prefer `GET /v1/schema` first and read `field_capabilities` rather than guessing `filter[...]` shapes against the records endpoint. Pass `connection_id` (or the deprecated `connector_instance_id` alias) to restrict to a single connection; omitted, the response aggregates across the connections the grant authorizes.", + "summary": "Return stream metadata including declared query capabilities and advisory freshness. Owner-token current-capability callers can consult `GET /v1/schema` for per-field filter capabilities; client-token v0.1 metadata does not advertise typed filter capabilities and client reads reject `filter[...]`. Pass `connection_id` (or the deprecated `connector_instance_id` alias) to restrict to a single connection; omitted, the response aggregates across the connections the grant authorizes.", "tags": [ "records" ] @@ -7308,7 +9056,7 @@ "name": "filter", "required": false, "schema": { - "description": "Per-field filter map. Exact: `filter[field]=value`. Range: `filter[field][op]=value` where `op` is one of the declared `field_capabilities.range_filter.operators` from `GET /v1/schema`.", + "description": "Owner-token current-capability filter map only. Client-token v0.1 reads reject exact `filter[field]=value` and range `filter[field][op]=value` before consulting current source metadata.", "type": "object" } }, @@ -8018,7 +9766,7 @@ } } }, - "summary": "List records in a stream under grant enforcement. Supports logical-cursor pagination, exact and declared range filters, declared one-hop expansion, and changes_since. Per-field filter operators, sortable fields, expandable relations, projection, search modes, and count support are advertised by `GET /v1/schema` (`field_capabilities`, `expand_capabilities`); consult it before issuing `filter[...]`, `expand[]`, or `fields=` shapes to avoid 400 errors. Pass `connection_id` to restrict to one connection; the deprecated `connector_instance_id` alias is accepted for compatibility but new clients SHOULD use `connection_id`.", + "summary": "List records in a stream under grant enforcement. Supports logical-cursor pagination, declared one-hop expansion, and changes_since. Client-token v0.1 reads reject exact and range `filter[...]` parameters before consulting current source metadata; owner-token current-capability reads MAY use declared filters. Per-field query capabilities are advertised by `GET /v1/schema`; consult it before issuing supported query shapes. Pass `connection_id` to restrict to one connection; the deprecated `connector_instance_id` alias is accepted for compatibility but new clients SHOULD use `connection_id`.", "tags": [ "records" ] @@ -8638,7 +10386,7 @@ } } }, - "summary": "Compute a single-stream grant-safe aggregation. Supports count, numeric sum, numeric/date min/max, exact count_distinct, scalar grouped counts (`group_by`), calendar time-bucket counts (`group_by_time`+`granularity`, optional `time_zone` defaulting to UTC), and existing exact/range filters over declared fields. Exactly one grouping dimension per call: `group_by` XOR `group_by_time`. Grouped responses include `other_count` (sum of counts for groups/buckets beyond `limit`) so callers can detect truncation without a second round trip.", + "summary": "Compute a single-stream grant-safe aggregation. Supports count, numeric sum, numeric/date min/max, exact count_distinct, scalar grouped counts (`group_by`), calendar time-bucket counts (`group_by_time`+`granularity`, optional `time_zone` defaulting to UTC), and owner-token current-capability exact/range filters over declared fields. Client-token v0.1 reads reject `filter[...]`. Exactly one grouping dimension per call: `group_by` XOR `group_by_time`. Grouped responses include `other_count` (sum of counts for groups/buckets beyond `limit`) so callers can detect truncation without a second round trip.", "tags": [ "records" ] @@ -9847,7 +11595,7 @@ } } }, - "summary": "Optional lexical retrieval extension: search records across authorized streams by text. Search modes, per-mode cursor support, and field-level `lexical_search`/`semantic_search` capabilities are advertised by `GET /v1/schema`; `filter[...]` operators applied to a single named stream must come from that stream's `field_capabilities`. Hits carry `connection_id` for attribution; the deprecated `connector_instance_id` alias is emitted alongside for compatibility but new clients SHOULD read `connection_id`.", + "summary": "Optional lexical retrieval extension: search records across authorized streams by text. Search modes, per-mode cursor support, and field-level `lexical_search`/`semantic_search` capabilities are advertised by `GET /v1/schema`. Client-token v0.1 reads reject `filter[...]`; owner-token current-capability reads MAY use declared filters. Hits carry `connection_id` for attribution; the deprecated `connector_instance_id` alias is emitted alongside for compatibility but new clients SHOULD read `connection_id`.", "tags": [ "records", "lexical-retrieval" @@ -13330,7 +15078,269 @@ "stalled_work": { "type": "array", "items": { - "type": "object", + "type": "object", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_instance_id": { + "type": "string" + }, + "display_name": { + "type": "string" + } + }, + "required": [ + "connection_id", + "connector_id", + "connector_instance_id", + "display_name" + ] + } + }, + "system": { + "type": "object", + "additionalProperties": false, + "properties": { + "degraded_or_broken": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_instance_id": { + "type": "string" + }, + "display_name": { + "type": "string" + } + }, + "required": [ + "connection_id", + "connector_id", + "connector_instance_id", + "display_name" + ] + } + } + }, + "required": [ + "degraded_or_broken" + ] + }, + "unknown_evidence": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_instance_id": { + "type": "string" + }, + "display_name": { + "type": "string" + } + }, + "required": [ + "connection_id", + "connector_id", + "connector_instance_id", + "display_name" + ] + } + } + }, + "required": [ + "active_work", + "attention", + "coverage_audit", + "freshness_advisories", + "intentional_policy", + "recovery", + "runtime", + "stalled_work", + "system", + "unknown_evidence" + ] + }, + "fully_healthy": { + "type": "boolean" + }, + "scope": { + "type": "object", + "additionalProperties": false, + "properties": { + "assessed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_instance_id": { + "type": "string" + }, + "display_name": { + "type": "string" + } + }, + "required": [ + "connection_id", + "connector_id", + "connector_instance_id", + "display_name" + ] + } + }, + "configured": { + "type": "integer", + "minimum": 0 + }, + "intentional_exclusions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_instance_id": { + "type": "string" + }, + "display_name": { + "type": "string" + } + }, + "required": [ + "connection_id", + "connector_id", + "connector_instance_id", + "display_name" + ] + } + }, + "setup_pending": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_instance_id": { + "type": "string" + }, + "display_name": { + "type": "string" + } + }, + "required": [ + "connection_id", + "connector_id", + "connector_instance_id", + "display_name" + ] + } + }, + "unassessed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_instance_id": { + "type": "string" + }, + "display_name": { + "type": "string" + } + }, + "required": [ + "connection_id", + "connector_id", + "connector_instance_id", + "display_name" + ] + } + } + }, + "required": [ + "assessed", + "configured", + "intentional_exclusions", + "setup_pending", + "unassessed" + ] + }, + "state": { + "type": "string", + "enum": [ + "healthy", + "healthy_with_advisories", + "indeterminate", + "unhealthy" + ] + } + }, + "required": [ + "dimensions", + "fully_healthy", + "scope", + "state" + ] + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", "additionalProperties": false, "properties": { "connection_id": { @@ -13339,61 +15349,86 @@ "connector_id": { "type": "string" }, - "connector_instance_id": { + "connector_key": { "type": "string" }, "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], "type": "string" } }, "required": [ - "connection_id", - "connector_id", - "connector_instance_id", - "display_name" - ] - } - }, - "system": { - "type": "object", - "additionalProperties": false, - "properties": { - "degraded_or_broken": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "connection_id": { - "type": "string" - }, - "connector_id": { - "type": "string" - }, - "connector_instance_id": { - "type": "string" - }, - "display_name": { - "type": "string" - } - }, - "required": [ - "connection_id", - "connector_id", - "connector_instance_id", - "display_name" - ] - } - } + "connection_id" + ], + "type": "object" }, - "required": [ - "degraded_or_broken" - ] + "type": "array" }, - "unknown_evidence": { - "type": "array", + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { "items": { - "type": "object", + "$id": "pdpp/common/ErrorAvailableConnection", "additionalProperties": false, "properties": { "connection_id": { @@ -13402,46 +15437,86 @@ "connector_id": { "type": "string" }, - "connector_instance_id": { + "connector_key": { "type": "string" }, "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], "type": "string" } }, "required": [ - "connection_id", - "connector_id", - "connector_instance_id", - "display_name" - ] - } + "connection_id" + ], + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" } }, "required": [ - "active_work", - "attention", - "coverage_audit", - "freshness_advisories", - "intentional_policy", - "recovery", - "runtime", - "stalled_work", - "system", - "unknown_evidence" - ] - }, - "fully_healthy": { - "type": "boolean" - }, - "scope": { - "type": "object", + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "Conflict (e.g. run_already_active)", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { "additionalProperties": false, "properties": { - "assessed": { - "type": "array", + "available_connections": { "items": { - "type": "object", + "$id": "pdpp/common/ErrorAvailableConnection", "additionalProperties": false, "properties": { "connection_id": { @@ -13450,131 +15525,265 @@ "connector_id": { "type": "string" }, - "connector_instance_id": { + "connector_key": { "type": "string" }, "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], "type": "string" } }, "required": [ - "connection_id", - "connector_id", - "connector_instance_id", - "display_name" - ] - } + "connection_id" + ], + "type": "object" + }, + "type": "array" }, - "configured": { - "type": "integer", - "minimum": 0 + "code": { + "type": "string" }, - "intentional_exclusions": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "connection_id": { - "type": "string" - }, - "connector_id": { - "type": "string" - }, - "connector_instance_id": { - "type": "string" - }, - "display_name": { - "type": "string" - } - }, - "required": [ - "connection_id", - "connector_id", - "connector_instance_id", - "display_name" - ] - } + "message": { + "type": "string" }, - "setup_pending": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "connection_id": { - "type": "string" - }, - "connector_id": { - "type": "string" - }, - "connector_instance_id": { - "type": "string" - }, - "display_name": { - "type": "string" - } - }, - "required": [ - "connection_id", - "connector_id", - "connector_instance_id", - "display_name" - ] - } + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + } + }, + "summary": "Get the owner-only composed fleet-health verdict for configured connections.", + "tags": [ + "reference", + "connectors", + "owner" + ] + } + }, + "/_ref/connectors/{connectorId}": { + "get": { + "operationId": "refGetConnector", + "parameters": [ + { + "in": "path", + "name": "connectorId", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "connector_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "freshness": { + "$id": "pdpp/common/Freshness", + "additionalProperties": false, + "properties": { + "captured_at": { + "format": "date-time", + "type": "string" + }, + "last_attempted_at": { + "format": "date-time", + "type": "string" + }, + "status": { + "$id": "pdpp/common/FreshnessStatus", + "enum": [ + "current", + "stale", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "last_run": { + "additionalProperties": true, + "properties": { + "event_count": { + "type": "integer" + }, + "failure_reason": { + "type": [ + "string", + "null" + ] + }, + "finished_at": { + "type": [ + "string", + "null" + ] + }, + "first_at": { + "type": "string" + }, + "last_at": { + "type": "string" + }, + "run_id": { + "type": "string" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": [ + "object", + "null" + ] + }, + "last_successful_run": { + "additionalProperties": true, + "properties": { + "event_count": { + "type": "integer" + }, + "failure_reason": { + "type": [ + "string", + "null" + ] + }, + "finished_at": { + "type": [ + "string", + "null" + ] + }, + "first_at": { + "type": "string" + }, + "last_at": { + "type": "string" + }, + "run_id": { + "type": "string" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": [ + "object", + "null" + ] + }, + "manifest_version": { + "type": "string" + }, + "schedule": { + "additionalProperties": true, + "properties": { + "enabled": { + "type": "boolean" }, - "unassessed": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "connection_id": { - "type": "string" - }, - "connector_id": { - "type": "string" - }, - "connector_instance_id": { - "type": "string" - }, - "display_name": { - "type": "string" - } - }, - "required": [ - "connection_id", - "connector_id", - "connector_instance_id", - "display_name" - ] - } + "interval_seconds": { + "type": "integer" + }, + "jitter_seconds": { + "type": "integer" + }, + "next_due_at": { + "type": [ + "string", + "null" + ] } }, - "required": [ - "assessed", - "configured", - "intentional_exclusions", - "setup_pending", - "unassessed" + "type": [ + "object", + "null" ] }, - "state": { - "type": "string", + "streams": { + "items": { + "type": "string" + }, + "type": "array" + }, + "total_records": { + "type": "integer" + }, + "total_records_state": { "enum": [ - "healthy", - "healthy_with_advisories", - "indeterminate", - "unhealthy" - ] + "known", + "known_zero", + "unobserved", + "stale", + "unknown" + ], + "type": "string" } }, "required": [ - "dimensions", - "fully_healthy", - "scope", - "state" - ] + "connector_id" + ], + "type": "object" } } } @@ -13797,243 +16006,173 @@ "required": [ "connection_id" ], - "type": "object" - }, - "type": "array" - }, - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "next_step": { - "type": "string" - }, - "param": { - "type": "string" - }, - "request_id": { - "type": "string" - }, - "resource_metadata": { - "type": "string" - }, - "retry_with": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "type", - "code", - "message", - "request_id" - ], - "type": "object" - } - }, - "required": [ - "error" - ], - "type": "object" - } - } - } - } - }, - "summary": "Get the owner-only composed fleet-health verdict for configured connections.", - "tags": [ - "reference", - "connectors", - "owner" - ] - } - }, - "/_ref/connectors/{connectorId}": { - "get": { - "operationId": "refGetConnector", - "parameters": [ - { - "in": "path", - "name": "connectorId", - "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "properties": { - "connector_id": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "freshness": { - "$id": "pdpp/common/Freshness", - "additionalProperties": false, - "properties": { - "captured_at": { - "format": "date-time", - "type": "string" - }, - "last_attempted_at": { - "format": "date-time", - "type": "string" - }, - "status": { - "$id": "pdpp/common/FreshnessStatus", - "enum": [ - "current", - "stale", - "unknown" - ], - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" - }, - "last_run": { - "additionalProperties": true, - "properties": { - "event_count": { - "type": "integer" - }, - "failure_reason": { - "type": [ - "string", - "null" - ] - }, - "finished_at": { - "type": [ - "string", - "null" - ] - }, - "first_at": { - "type": "string" - }, - "last_at": { - "type": "string" - }, - "run_id": { - "type": "string" - }, - "started_at": { - "type": "string" - }, - "status": { - "type": "string" - } - }, - "type": [ - "object", - "null" - ] - }, - "last_successful_run": { - "additionalProperties": true, - "properties": { - "event_count": { - "type": "integer" - }, - "failure_reason": { - "type": [ - "string", - "null" - ] - }, - "finished_at": { - "type": [ - "string", - "null" - ] + "type": "object" + }, + "type": "array" }, - "first_at": { + "code": { "type": "string" }, - "last_at": { + "message": { "type": "string" }, - "run_id": { + "next_step": { "type": "string" }, - "started_at": { + "param": { "type": "string" }, - "status": { + "request_id": { "type": "string" - } - }, - "type": [ - "object", - "null" - ] - }, - "manifest_version": { - "type": "string" - }, - "schedule": { - "additionalProperties": true, - "properties": { - "enabled": { - "type": "boolean" }, - "interval_seconds": { - "type": "integer" + "resource_metadata": { + "type": "string" }, - "jitter_seconds": { - "type": "integer" + "retry_with": { + "type": "string" }, - "next_due_at": { - "type": [ - "string", - "null" - ] + "type": { + "type": "string" } }, - "type": [ - "object", - "null" - ] - }, - "streams": { + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + } + }, + "summary": "Get a single connector with manifest excerpt, schedule, recent runs, and stream summaries.", + "tags": [ + "reference", + "connectors" + ] + } + }, + "/_ref/connections": { + "get": { + "operationId": "refListConnections", + "parameters": [ + { + "in": "query", + "name": "connector_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "enum": [ + "active", + "paused", + "revoked" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { "items": { - "type": "string" + "additionalProperties": true, + "properties": { + "connector_id": { + "type": "string" + }, + "connector_instance_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "object": { + "const": "ref_connection" + }, + "revoked_at": { + "type": [ + "string", + "null" + ] + }, + "schedule": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "source_binding": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "source_kind": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "object", + "connector_instance_id", + "connector_id", + "display_name", + "status", + "source_kind", + "source_binding", + "created_at", + "updated_at", + "revoked_at", + "schedule" + ], + "type": "object" }, "type": "array" }, - "total_records": { - "type": "integer" - }, - "total_records_state": { - "enum": [ - "known", - "known_zero", - "unobserved", - "stale", - "unknown" - ], - "type": "string" + "object": { + "const": "list" } }, "required": [ - "connector_id" + "object", + "data" ], "type": "object" } @@ -14305,20 +16444,29 @@ } } }, - "summary": "Get a single connector with manifest excerpt, schedule, recent runs, and stream summaries.", + "summary": "List owner-facing configured connector connections with labels, lifecycle status, binding metadata, and schedules.", "tags": [ "reference", - "connectors" + "connections" ] } }, - "/_ref/connections": { + "/_ref/connections/{connectorInstanceId}/record-rejections": { "get": { - "operationId": "refListConnections", + "operationId": "refListRecordRejections", "parameters": [ + { + "in": "path", + "name": "connectorInstanceId", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, { "in": "query", - "name": "connector_id", + "name": "cursor", "required": false, "schema": { "type": "string" @@ -14326,15 +16474,12 @@ }, { "in": "query", - "name": "status", + "name": "limit", "required": false, "schema": { - "enum": [ - "active", - "paused", - "revoked" - ], - "type": "string" + "maximum": 100, + "minimum": 1, + "type": "integer" } } ], @@ -14348,83 +16493,108 @@ "properties": { "data": { "items": { - "additionalProperties": true, + "additionalProperties": false, "properties": { - "connector_id": { + "connection_id": { + "minLength": 1, "type": "string" }, - "connector_instance_id": { + "connector_id": { + "minLength": 1, "type": "string" }, "created_at": { + "format": "date-time", "type": "string" }, - "display_name": { - "type": [ - "string", - "null" - ] + "first_input_index": { + "minimum": 0, + "type": "integer" + }, + "last_seen_at": { + "format": "date-time", + "type": "string" + }, + "latest_input_index": { + "minimum": 0, + "type": "integer" + }, + "payload_bytes": { + "minimum": 0, + "type": "integer" + }, + "payload_sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" }, - "object": { - "const": "ref_connection" + "quota_near_limit": { + "type": "boolean" }, - "revoked_at": { - "type": [ - "string", - "null" - ] + "reason_code": { + "minLength": 1, + "type": "string" }, - "schedule": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] + "receipt_id": { + "minLength": 1, + "type": "string" }, - "source_binding": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] + "replay_count": { + "minimum": 0, + "type": "integer" }, - "source_kind": { + "run_id": { "type": [ "string", "null" ] }, "status": { - "type": "string" + "const": "pending" }, - "updated_at": { + "stream": { + "minLength": 1, "type": "string" } }, "required": [ - "object", - "connector_instance_id", + "connection_id", "connector_id", - "display_name", - "status", - "source_kind", - "source_binding", "created_at", - "updated_at", - "revoked_at", - "schedule" + "first_input_index", + "last_seen_at", + "latest_input_index", + "payload_bytes", + "payload_sha256", + "quota_near_limit", + "reason_code", + "receipt_id", + "replay_count", + "run_id", + "status", + "stream" ], "type": "object" }, "type": "array" }, + "has_more": { + "type": "boolean" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + }, "object": { "const": "list" } }, "required": [ "object", - "data" + "data", + "has_more", + "next_cursor" ], "type": "object" } @@ -14696,16 +16866,17 @@ } } }, - "summary": "List owner-facing configured connector connections with labels, lifecycle status, binding metadata, and schedules.", + "summary": "List metadata for pending durable record rejections on an owner-controlled connection.", "tags": [ "reference", - "connections" + "connections", + "record-rejections" ] } }, - "/_ref/connections/{connectorInstanceId}/record-rejections": { + "/_ref/connections/{connectorInstanceId}/record-rejections/{receiptId}": { "get": { - "operationId": "refListRecordRejections", + "operationId": "refGetRecordRejection", "parameters": [ { "in": "path", @@ -14717,22 +16888,13 @@ } }, { - "in": "query", - "name": "cursor", - "required": false, + "in": "path", + "name": "receiptId", + "required": true, "schema": { + "minLength": 1, "type": "string" } - }, - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "maximum": 100, - "minimum": 1, - "type": "integer" - } } ], "responses": { @@ -14743,110 +16905,98 @@ "schema": { "additionalProperties": false, "properties": { - "data": { - "items": { - "additionalProperties": false, - "properties": { - "connection_id": { - "minLength": 1, - "type": "string" - }, - "connector_id": { - "minLength": 1, - "type": "string" - }, - "created_at": { - "format": "date-time", - "type": "string" - }, - "first_input_index": { - "minimum": 0, - "type": "integer" - }, - "last_seen_at": { - "format": "date-time", - "type": "string" - }, - "latest_input_index": { - "minimum": 0, - "type": "integer" - }, - "payload_bytes": { - "minimum": 0, - "type": "integer" - }, - "payload_sha256": { - "pattern": "^[a-f0-9]{64}$", - "type": "string" - }, - "quota_near_limit": { - "type": "boolean" - }, - "reason_code": { - "minLength": 1, - "type": "string" - }, - "receipt_id": { - "minLength": 1, - "type": "string" - }, - "replay_count": { - "minimum": 0, - "type": "integer" - }, - "run_id": { - "type": [ - "string", - "null" - ] - }, - "status": { - "const": "pending" - }, - "stream": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "connection_id", - "connector_id", - "created_at", - "first_input_index", - "last_seen_at", - "latest_input_index", - "payload_bytes", - "payload_sha256", - "quota_near_limit", - "reason_code", - "receipt_id", - "replay_count", - "run_id", - "status", - "stream" - ], - "type": "object" - }, - "type": "array" + "connection_id": { + "minLength": 1, + "type": "string" }, - "has_more": { + "connector_id": { + "minLength": 1, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "first_input_index": { + "minimum": 0, + "type": "integer" + }, + "last_seen_at": { + "format": "date-time", + "type": "string" + }, + "latest_input_index": { + "minimum": 0, + "type": "integer" + }, + "payload_bytes": { + "minimum": 0, + "type": "integer" + }, + "payload_sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "quota_near_limit": { "type": "boolean" }, - "next_cursor": { + "reason_code": { + "minLength": 1, + "type": "string" + }, + "receipt_id": { + "minLength": 1, + "type": "string" + }, + "replay_count": { + "minimum": 0, + "type": "integer" + }, + "run_id": { "type": [ "string", "null" ] }, - "object": { - "const": "list" + "status": { + "const": "pending" + }, + "stream": { + "minLength": 1, + "type": "string" + }, + "payload_base64": { + "type": "string" + }, + "payload_encoding": { + "const": "base64" + }, + "payload_text": { + "type": [ + "string", + "null" + ] } }, "required": [ - "object", - "data", - "has_more", - "next_cursor" + "connection_id", + "connector_id", + "created_at", + "first_input_index", + "last_seen_at", + "latest_input_index", + "payload_bytes", + "payload_sha256", + "quota_near_limit", + "reason_code", + "receipt_id", + "replay_count", + "run_id", + "status", + "stream", + "payload_base64", + "payload_encoding", + "payload_text" ], "type": "object" } @@ -15118,7 +17268,7 @@ } } }, - "summary": "List metadata for pending durable record rejections on an owner-controlled connection.", + "summary": "Get one retained record-rejection payload after owner and connection authorization.", "tags": [ "reference", "connections", @@ -15126,25 +17276,28 @@ ] } }, - "/_ref/connections/{connectorInstanceId}/record-rejections/{receiptId}": { + "/_ref/connector-instances": { "get": { - "operationId": "refGetRecordRejection", + "operationId": "refListConnectorInstances", "parameters": [ { - "in": "path", - "name": "connectorInstanceId", - "required": true, + "in": "query", + "name": "connector_id", + "required": false, "schema": { - "minLength": 1, "type": "string" } }, { - "in": "path", - "name": "receiptId", - "required": true, + "in": "query", + "name": "status", + "required": false, "schema": { - "minLength": 1, + "enum": [ + "active", + "paused", + "revoked" + ], "type": "string" } } @@ -15157,98 +17310,85 @@ "schema": { "additionalProperties": false, "properties": { - "connection_id": { - "minLength": 1, - "type": "string" - }, - "connector_id": { - "minLength": 1, - "type": "string" - }, - "created_at": { - "format": "date-time", - "type": "string" - }, - "first_input_index": { - "minimum": 0, - "type": "integer" - }, - "last_seen_at": { - "format": "date-time", - "type": "string" - }, - "latest_input_index": { - "minimum": 0, - "type": "integer" - }, - "payload_bytes": { - "minimum": 0, - "type": "integer" - }, - "payload_sha256": { - "pattern": "^[a-f0-9]{64}$", - "type": "string" - }, - "quota_near_limit": { - "type": "boolean" - }, - "reason_code": { - "minLength": 1, - "type": "string" - }, - "receipt_id": { - "minLength": 1, - "type": "string" - }, - "replay_count": { - "minimum": 0, - "type": "integer" - }, - "run_id": { - "type": [ - "string", - "null" - ] - }, - "status": { - "const": "pending" - }, - "stream": { - "minLength": 1, - "type": "string" - }, - "payload_base64": { - "type": "string" - }, - "payload_encoding": { - "const": "base64" + "data": { + "items": { + "additionalProperties": true, + "properties": { + "connector_id": { + "type": "string" + }, + "connector_instance_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "object": { + "const": "ref_connection" + }, + "revoked_at": { + "type": [ + "string", + "null" + ] + }, + "schedule": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "source_binding": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "source_kind": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "object", + "connector_instance_id", + "connector_id", + "display_name", + "status", + "source_kind", + "source_binding", + "created_at", + "updated_at", + "revoked_at", + "schedule" + ], + "type": "object" + }, + "type": "array" }, - "payload_text": { - "type": [ - "string", - "null" - ] + "object": { + "const": "list" } }, "required": [ - "connection_id", - "connector_id", - "created_at", - "first_input_index", - "last_seen_at", - "latest_input_index", - "payload_bytes", - "payload_sha256", - "quota_near_limit", - "reason_code", - "receipt_id", - "replay_count", - "run_id", - "status", - "stream", - "payload_base64", - "payload_encoding", - "payload_text" + "object", + "data" ], "type": "object" } @@ -15520,17 +17660,16 @@ } } }, - "summary": "Get one retained record-rejection payload after owner and connection authorization.", + "summary": "Compatibility alias for listing configured connector instances behind owner-facing connections.", "tags": [ "reference", - "connections", - "record-rejections" + "connections" ] } }, - "/_ref/connector-instances": { + "/v1/owner/connections": { "get": { - "operationId": "refListConnectorInstances", + "operationId": "ownerListConnections", "parameters": [ { "in": "query", @@ -15566,23 +17705,39 @@ "items": { "additionalProperties": true, "properties": { + "connection_id": { + "type": "string" + }, "connector_id": { "type": "string" }, "connector_instance_id": { "type": "string" }, - "created_at": { + "connector_key": { "type": "string" }, + "created_at": { + "type": [ + "string", + "null" + ] + }, "display_name": { "type": [ "string", "null" ] }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + }, "object": { - "const": "ref_connection" + "const": "owner_connection" }, "revoked_at": { "type": [ @@ -15604,31 +17759,83 @@ "null" ] }, - "source_kind": { + "source_kind": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "supported_actions": { + "items": { + "additionalProperties": false, + "properties": { + "family": { + "type": "string" + }, + "method": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": "string" + }, + "status": { + "enum": [ + "supported", + "owner_mediated", + "unsupported" + ], + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "family", + "status", + "method", + "url", + "reason" + ], + "type": "object" + }, + "type": "array" + }, + "updated_at": { "type": [ "string", "null" ] - }, - "status": { - "type": "string" - }, - "updated_at": { - "type": "string" } }, "required": [ "object", + "connection_id", "connector_instance_id", "connector_id", + "connector_key", "display_name", + "label_status", "status", "source_kind", "source_binding", "created_at", "updated_at", "revoked_at", - "schedule" + "schedule", + "supported_actions" ], "type": "object" }, @@ -15912,39 +18119,18 @@ } } }, - "summary": "Compatibility alias for listing configured connector instances behind owner-facing connections.", + "summary": "Owner-agent bearer listing of configured connections with connection_id, connector_key, owner-meaningful display_name, label status, lifecycle fields, and schedules.", "tags": [ "reference", - "connections" + "connections", + "owner-agent" ] } }, - "/v1/owner/connections": { + "/v1/owner/connector-templates": { "get": { - "operationId": "ownerListConnections", - "parameters": [ - { - "in": "query", - "name": "connector_id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "status", - "required": false, - "schema": { - "enum": [ - "active", - "paused", - "revoked" - ], - "type": "string" - } - } - ], + "operationId": "ownerListConnectorTemplates", + "parameters": [], "responses": { "200": { "description": "", @@ -15955,73 +18141,187 @@ "properties": { "data": { "items": { - "additionalProperties": true, + "additionalProperties": false, "properties": { - "connection_id": { - "type": "string" + "connection_count": { + "minimum": 0, + "type": "integer" }, - "connector_id": { - "type": "string" + "connections": { + "items": { + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_instance_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + }, + "object": { + "const": "owner_connection_summary" + }, + "revoked_at": { + "type": [ + "string", + "null" + ] + }, + "source_kind": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "object", + "connection_id", + "connector_instance_id", + "connector_id", + "connector_key", + "display_name", + "label_status", + "status", + "source_kind", + "created_at", + "updated_at", + "revoked_at" + ], + "type": "object" + }, + "type": "array" }, - "connector_instance_id": { + "connector_id": { "type": "string" }, "connector_key": { "type": "string" }, - "created_at": { - "type": [ - "string", - "null" - ] + "connector_modality": { + "enum": [ + "local_collector", + "browser_bound", + "api_network", + "unknown" + ], + "type": "string" }, "display_name": { - "type": [ - "string", - "null" - ] + "type": "string" }, - "label_status": { - "enum": [ - "owner_set", - "fallback" + "object": { + "const": "owner_connector_template" + }, + "setup_plan": { + "additionalProperties": true, + "properties": { + "deployment_readiness": { + "additionalProperties": true, + "type": "object" + }, + "next_step_kind": { + "enum": [ + "enroll_local_collector", + "enroll_browser_collector", + "capture_static_secret", + "open_provider_auth", + "needs_deployment_config", + "provide_import_file", + "manual_runbook", + "unsupported" + ], + "type": "string" + }, + "proof_gate": { + "type": [ + "string", + "null" + ] + }, + "runbook_path": { + "type": [ + "string", + "null" + ] + }, + "setup_modality": { + "enum": [ + "local_collector", + "browser_bound", + "static_secret", + "provider_authorization", + "manual_or_upload", + "unsupported", + "unknown" + ], + "type": "string" + }, + "support_state": { + "enum": [ + "supported", + "proof_gated", + "unsupported", + "needs_deployment_config" + ], + "type": "string" + }, + "validation": { + "enum": [ + "synchronous", + "first_sync" + ], + "type": "string" + } + }, + "required": [ + "setup_modality", + "support_state", + "next_step_kind", + "proof_gate", + "runbook_path" ], - "type": "string" - }, - "object": { - "const": "owner_connection" - }, - "revoked_at": { - "type": [ - "string", - "null" - ] - }, - "schedule": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "source_binding": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "source_kind": { - "type": [ - "string", - "null" - ] + "type": "object" }, - "status": { - "type": [ - "string", - "null" - ] + "stream_count": { + "minimum": 0, + "type": "integer" }, "supported_actions": { "items": { @@ -16065,7 +18365,7 @@ }, "type": "array" }, - "updated_at": { + "version": { "type": [ "string", "null" @@ -16074,19 +18374,15 @@ }, "required": [ "object", - "connection_id", - "connector_instance_id", "connector_id", "connector_key", "display_name", - "label_status", - "status", - "source_kind", - "source_binding", - "created_at", - "updated_at", - "revoked_at", - "schedule", + "version", + "connector_modality", + "setup_plan", + "stream_count", + "connection_count", + "connections", "supported_actions" ], "type": "object" @@ -16371,7 +18667,7 @@ } } }, - "summary": "Owner-agent bearer listing of configured connections with connection_id, connector_key, owner-meaningful display_name, label status, lifecycle fields, and schedules.", + "summary": "Owner-agent bearer listing of connector templates separated from configured connection instances. Embeds related connection summaries and template-level supported_actions for adding new connections as typed intents.", "tags": [ "reference", "connections", @@ -16379,245 +18675,43 @@ ] } }, - "/v1/owner/connector-templates": { + "/v1/owner/control": { "get": { - "operationId": "ownerListConnectorTemplates", - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "data": { - "items": { - "additionalProperties": false, - "properties": { - "connection_count": { - "minimum": 0, - "type": "integer" - }, - "connections": { - "items": { - "additionalProperties": false, - "properties": { - "connection_id": { - "type": "string" - }, - "connector_id": { - "type": "string" - }, - "connector_instance_id": { - "type": "string" - }, - "connector_key": { - "type": "string" - }, - "created_at": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "label_status": { - "enum": [ - "owner_set", - "fallback" - ], - "type": "string" - }, - "object": { - "const": "owner_connection_summary" - }, - "revoked_at": { - "type": [ - "string", - "null" - ] - }, - "source_kind": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "updated_at": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "object", - "connection_id", - "connector_instance_id", - "connector_id", - "connector_key", - "display_name", - "label_status", - "status", - "source_kind", - "created_at", - "updated_at", - "revoked_at" - ], - "type": "object" - }, - "type": "array" - }, - "connector_id": { - "type": "string" - }, - "connector_key": { - "type": "string" - }, - "connector_modality": { - "enum": [ - "local_collector", - "browser_bound", - "api_network", - "unknown" - ], - "type": "string" - }, - "display_name": { - "type": "string" - }, - "object": { - "const": "owner_connector_template" - }, - "setup_plan": { - "additionalProperties": true, - "properties": { - "deployment_readiness": { - "additionalProperties": true, - "type": "object" - }, - "next_step_kind": { - "enum": [ - "enroll_local_collector", - "enroll_browser_collector", - "capture_static_secret", - "open_provider_auth", - "needs_deployment_config", - "provide_import_file", - "manual_runbook", - "unsupported" - ], - "type": "string" - }, - "proof_gate": { - "type": [ - "string", - "null" - ] - }, - "runbook_path": { - "type": [ - "string", - "null" - ] - }, - "setup_modality": { - "enum": [ - "local_collector", - "browser_bound", - "static_secret", - "provider_authorization", - "manual_or_upload", - "unsupported", - "unknown" - ], - "type": "string" - }, - "support_state": { - "enum": [ - "supported", - "proof_gated", - "unsupported", - "needs_deployment_config" - ], - "type": "string" - }, - "validation": { - "enum": [ - "synchronous", - "first_sync" - ], - "type": "string" - } - }, - "required": [ - "setup_modality", - "support_state", - "next_step_kind", - "proof_gate", - "runbook_path" - ], - "type": "object" + "operationId": "ownerControlCapabilities", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "actions": { + "items": { + "additionalProperties": false, + "properties": { + "family": { + "type": "string" }, - "stream_count": { - "minimum": 0, - "type": "integer" + "method": { + "type": [ + "string", + "null" + ] }, - "supported_actions": { - "items": { - "additionalProperties": false, - "properties": { - "family": { - "type": "string" - }, - "method": { - "type": [ - "string", - "null" - ] - }, - "reason": { - "type": "string" - }, - "status": { - "enum": [ - "supported", - "owner_mediated", - "unsupported" - ], - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "family", - "status", - "method", - "url", - "reason" - ], - "type": "object" - }, - "type": "array" + "reason": { + "type": "string" }, - "version": { + "status": { + "enum": [ + "supported", + "owner_mediated", + "unsupported" + ], + "type": "string" + }, + "url": { "type": [ "string", "null" @@ -16625,29 +18719,35 @@ } }, "required": [ - "object", - "connector_id", - "connector_key", - "display_name", - "version", - "connector_modality", - "setup_plan", - "stream_count", - "connection_count", - "connections", - "supported_actions" + "family", + "status", + "method", + "url", + "reason" ], "type": "object" }, "type": "array" }, + "entrypoint": { + "type": "string" + }, + "mcp_owner_bearer_rejected": { + "const": true + }, "object": { - "const": "list" + "const": "owner_agent_control_surface" + }, + "scope": { + "const": "reference_implementation" } }, "required": [ "object", - "data" + "entrypoint", + "scope", + "mcp_owner_bearer_rejected", + "actions" ], "type": "object" } @@ -16919,27 +19019,102 @@ } } }, - "summary": "Owner-agent bearer listing of connector templates separated from configured connection instances. Embeds related connection summaries and template-level supported_actions for adding new connections as typed intents.", + "summary": "Owner-agent bearer control entrypoint: capability document naming supported, owner-mediated, and unsupported owner-agent control action families with links to supported routes.", "tags": [ "reference", - "connections", "owner-agent" ] } }, - "/v1/owner/control": { - "get": { - "operationId": "ownerControlCapabilities", - "parameters": [], + "/v1/owner/connections/{connectionId}": { + "patch": { + "operationId": "ownerSetConnectionDisplayName", + "parameters": [ + { + "in": "path", + "name": "connectionId", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "additionalProperties": false, + "additionalProperties": true, "properties": { - "actions": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_instance_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + }, + "object": { + "const": "owner_connection" + }, + "revoked_at": { + "type": [ + "string", + "null" + ] + }, + "schedule": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "source_binding": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "source_kind": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "supported_actions": { "items": { "additionalProperties": false, "properties": { @@ -16981,25 +19156,29 @@ }, "type": "array" }, - "entrypoint": { - "type": "string" - }, - "mcp_owner_bearer_rejected": { - "const": true - }, - "object": { - "const": "owner_agent_control_surface" - }, - "scope": { - "const": "reference_implementation" + "updated_at": { + "type": [ + "string", + "null" + ] } }, "required": [ "object", - "entrypoint", - "scope", - "mcp_owner_bearer_rejected", - "actions" + "connection_id", + "connector_instance_id", + "connector_id", + "connector_key", + "display_name", + "label_status", + "status", + "source_kind", + "source_binding", + "created_at", + "updated_at", + "revoked_at", + "schedule", + "supported_actions" ], "type": "object" } @@ -17271,16 +19450,36 @@ } } }, - "summary": "Owner-agent bearer control entrypoint: capability document naming supported, owner-mediated, and unsupported owner-agent control action families with links to supported routes.", + "summary": "Owner-agent bearer rename of the owner-meaningful `display_name` on a connection, addressed by `connection_id`. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. Shares the connector-instance store rename semantics with the cookie-authed `/_ref` PATCH; on success the returned row reports label_status owner_set.", "tags": [ "reference", + "connections", "owner-agent" - ] - } - }, - "/v1/owner/connections/{connectionId}": { - "patch": { - "operationId": "ownerSetConnectionDisplayName", + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "display_name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "display_name" + ], + "type": "object" + } + } + }, + "required": true + } + }, + "delete": { + "operationId": "ownerDeleteConnection", "parameters": [ { "in": "path", @@ -17294,11 +19493,11 @@ ], "responses": { "200": { - "description": "", + "description": "Deleted", "content": { "application/json": { "schema": { - "additionalProperties": true, + "additionalProperties": false, "properties": { "connection_id": { "type": "string" @@ -17306,131 +19505,38 @@ "connector_id": { "type": "string" }, - "connector_instance_id": { - "type": "string" - }, "connector_key": { "type": "string" }, - "created_at": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "label_status": { - "enum": [ - "owner_set", - "fallback" - ], - "type": "string" - }, - "object": { - "const": "owner_connection" - }, - "revoked_at": { - "type": [ - "string", - "null" - ] - }, - "schedule": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] + "deleted": { + "const": true }, - "source_binding": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] + "deleted_record_count": { + "type": "integer" }, - "source_kind": { - "type": [ - "string", - "null" - ] + "deleted_stream_count": { + "type": "integer" }, - "status": { - "type": [ - "string", - "null" - ] + "device_refs_cleared": { + "type": "integer" }, - "supported_actions": { - "items": { - "additionalProperties": false, - "properties": { - "family": { - "type": "string" - }, - "method": { - "type": [ - "string", - "null" - ] - }, - "reason": { - "type": "string" - }, - "status": { - "enum": [ - "supported", - "owner_mediated", - "unsupported" - ], - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "family", - "status", - "method", - "url", - "reason" - ], - "type": "object" - }, - "type": "array" + "object": { + "const": "owner_connection_delete" }, - "updated_at": { - "type": [ - "string", - "null" - ] + "schedule_deleted": { + "type": "boolean" } }, "required": [ "object", "connection_id", - "connector_instance_id", "connector_id", "connector_key", - "display_name", - "label_status", - "status", - "source_kind", - "source_binding", - "created_at", - "updated_at", - "revoked_at", - "schedule", - "supported_actions" + "deleted", + "deleted_record_count", + "deleted_stream_count", + "schedule_deleted", + "device_refs_cleared" ], "type": "object" } @@ -17702,93 +19808,200 @@ } } }, - "summary": "Owner-agent bearer rename of the owner-meaningful `display_name` on a connection, addressed by `connection_id`. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. Shares the connector-instance store rename semantics with the cookie-authed `/_ref` PATCH; on success the returned row reports label_status owner_set.", + "summary": "Owner-agent bearer: DESTRUCTIVELY delete one configured connection, addressed by `connection_id`. Erases that connection's records, record-change history, version counters, blobs, blob bindings, search indices, and attention records, deletes its schedule, clears its device source-instance back-reference, and removes the connector_instances row — all keyed strictly on one connection_id, never widening to connector_id (sibling connections of the same connector type are untouched). It does NOT erase a running collection: a connection with an in-flight run is REFUSED, not deleted (no active-run row is erased while running). The source-of-truth deletion (records, history, version counters, blobs, blob bindings, attention, schedule, device back-ref, and the connector_instances row) is transactional all-or-nothing across one connector_instance_id; the search-index teardown is a rebuildable projection cleaned up after that commit. PRESERVES the audit spine (appending an owner_agent.connection.delete event), disclosure grants, and the device edge. Delete is NOT revoke: it erases the past and removes the configuration, where revoke only stops the future. A repeat/unknown/foreign-owner id returns a typed `connector_instance_not_found` (404) without leaking existence. An in-flight run returns `connection_run_active` (409). A default-account binding returns `default_account_delete_unsupported` (409) — revoke it instead. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. `/mcp` owner-bearer rejection is untouched.", "tags": [ "reference", "connections", "owner-agent" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "display_name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - } - }, - "required": [ - "display_name" - ], - "type": "object" - } - } - }, - "required": true - } - }, - "delete": { - "operationId": "ownerDeleteConnection", - "parameters": [ - { - "in": "path", - "name": "connectionId", - "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - } - ], + ] + } + }, + "/v1/owner/connections/intents": { + "post": { + "operationId": "ownerCreateConnectionIntent", + "parameters": [], "responses": { - "200": { - "description": "Deleted", + "201": { + "description": "", "content": { "application/json": { "schema": { - "additionalProperties": false, + "additionalProperties": true, "properties": { - "connection_id": { - "type": "string" + "connection_active": { + "const": false }, "connector_id": { "type": "string" }, - "connector_key": { - "type": "string" + "connector_key": { + "type": "string" + }, + "connector_modality": { + "enum": [ + "local_collector", + "browser_bound", + "api_network", + "unknown" + ], + "type": "string" + }, + "deployment_readiness": { + "additionalProperties": true, + "properties": { + "blockers": { + "items": { + "additionalProperties": true, + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "secret": { + "type": "boolean" + } + }, + "required": [ + "key", + "label", + "secret" + ], + "type": "object" + }, + "type": "array" + }, + "guidance": { + "type": [ + "string", + "null" + ] + }, + "state": { + "enum": [ + "not_applicable", + "ready", + "needs_config" + ], + "type": "string" + } + }, + "required": [ + "state", + "guidance", + "blockers" + ], + "type": "object" + }, + "next_step": { + "additionalProperties": true, + "properties": { + "authorization_url": { + "type": "string" + }, + "capture_endpoint": { + "type": "string" + }, + "enroll_endpoint": { + "type": "string" + }, + "enrollment_code": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "kind": { + "enum": [ + "enroll_local_collector", + "enroll_browser_collector", + "capture_static_secret", + "open_provider_auth", + "needs_deployment_config", + "provide_import_file", + "manual_runbook", + "unsupported" + ], + "type": "string" + }, + "local_binding_name": { + "type": "string" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "runbook_path": { + "type": "string" + }, + "upload_endpoint": { + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" }, - "deleted": { - "const": true + "object": { + "const": "owner_connection_intent" }, - "deleted_record_count": { - "type": "integer" + "proof_gate": { + "type": [ + "string", + "null" + ] }, - "deleted_stream_count": { - "type": "integer" + "runbook_path": { + "type": [ + "string", + "null" + ] }, - "device_refs_cleared": { - "type": "integer" + "setup_modality": { + "enum": [ + "local_collector", + "browser_bound", + "static_secret", + "provider_authorization", + "manual_or_upload", + "unsupported", + "unknown" + ], + "type": "string" }, - "object": { - "const": "owner_connection_delete" + "support_state": { + "enum": [ + "supported", + "proof_gated", + "unsupported", + "needs_deployment_config" + ], + "type": "string" }, - "schedule_deleted": { - "type": "boolean" + "validation": { + "enum": [ + "synchronous", + "first_sync" + ], + "type": "string" } }, "required": [ "object", - "connection_id", "connector_id", "connector_key", - "deleted", - "deleted_record_count", - "deleted_stream_count", - "schedule_deleted", - "device_refs_cleared" + "connector_modality", + "connection_active", + "deployment_readiness", + "next_step", + "proof_gate", + "runbook_path", + "setup_modality", + "support_state" ], "type": "object" } @@ -18013,252 +20226,103 @@ "required": [ "connection_id" ], - "type": "object" - }, - "type": "array" - }, - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "next_step": { - "type": "string" - }, - "param": { - "type": "string" - }, - "request_id": { - "type": "string" - }, - "resource_metadata": { - "type": "string" - }, - "retry_with": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "type", - "code", - "message", - "request_id" - ], - "type": "object" - } - }, - "required": [ - "error" - ], - "type": "object" - } - } - } - } - }, - "summary": "Owner-agent bearer: DESTRUCTIVELY delete one configured connection, addressed by `connection_id`. Erases that connection's records, record-change history, version counters, blobs, blob bindings, search indices, and attention records, deletes its schedule, clears its device source-instance back-reference, and removes the connector_instances row — all keyed strictly on one connection_id, never widening to connector_id (sibling connections of the same connector type are untouched). It does NOT erase a running collection: a connection with an in-flight run is REFUSED, not deleted (no active-run row is erased while running). The source-of-truth deletion (records, history, version counters, blobs, blob bindings, attention, schedule, device back-ref, and the connector_instances row) is transactional all-or-nothing across one connector_instance_id; the search-index teardown is a rebuildable projection cleaned up after that commit. PRESERVES the audit spine (appending an owner_agent.connection.delete event), disclosure grants, and the device edge. Delete is NOT revoke: it erases the past and removes the configuration, where revoke only stops the future. A repeat/unknown/foreign-owner id returns a typed `connector_instance_not_found` (404) without leaking existence. An in-flight run returns `connection_run_active` (409). A default-account binding returns `default_account_delete_unsupported` (409) — revoke it instead. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. `/mcp` owner-bearer rejection is untouched.", - "tags": [ - "reference", - "connections", - "owner-agent" - ] - } - }, - "/v1/owner/connections/intents": { - "post": { - "operationId": "ownerCreateConnectionIntent", - "parameters": [], - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "properties": { - "connection_active": { - "const": false - }, - "connector_id": { - "type": "string" - }, - "connector_key": { - "type": "string" - }, - "connector_modality": { - "enum": [ - "local_collector", - "browser_bound", - "api_network", - "unknown" - ], - "type": "string" - }, - "deployment_readiness": { - "additionalProperties": true, - "properties": { - "blockers": { - "items": { - "additionalProperties": true, - "properties": { - "key": { - "type": "string" - }, - "label": { - "type": "string" - }, - "secret": { - "type": "boolean" - } - }, - "required": [ - "key", - "label", - "secret" - ], - "type": "object" - }, - "type": "array" - }, - "guidance": { - "type": [ - "string", - "null" - ] - }, - "state": { - "enum": [ - "not_applicable", - "ready", - "needs_config" - ], - "type": "string" - } - }, - "required": [ - "state", - "guidance", - "blockers" - ], - "type": "object" - }, - "next_step": { - "additionalProperties": true, - "properties": { - "authorization_url": { - "type": "string" + "type": "object" + }, + "type": "array" }, - "capture_endpoint": { + "code": { "type": "string" }, - "enroll_endpoint": { + "message": { "type": "string" }, - "enrollment_code": { + "next_step": { "type": "string" }, - "expires_at": { + "param": { "type": "string" }, - "kind": { - "enum": [ - "enroll_local_collector", - "enroll_browser_collector", - "capture_static_secret", - "open_provider_auth", - "needs_deployment_config", - "provide_import_file", - "manual_runbook", - "unsupported" - ], + "request_id": { "type": "string" }, - "local_binding_name": { + "resource_metadata": { "type": "string" }, - "reason": { - "type": [ - "string", - "null" - ] - }, - "runbook_path": { + "retry_with": { "type": "string" }, - "upload_endpoint": { + "type": { "type": "string" } }, "required": [ - "kind" + "type", + "code", + "message", + "request_id" ], "type": "object" - }, - "object": { - "const": "owner_connection_intent" - }, - "proof_gate": { - "type": [ - "string", - "null" - ] - }, - "runbook_path": { - "type": [ - "string", - "null" - ] - }, - "setup_modality": { - "enum": [ - "local_collector", - "browser_bound", - "static_secret", - "provider_authorization", - "manual_or_upload", - "unsupported", - "unknown" - ], - "type": "string" - }, - "support_state": { - "enum": [ - "supported", - "proof_gated", - "unsupported", - "needs_deployment_config" - ], - "type": "string" - }, - "validation": { - "enum": [ - "synchronous", - "first_sync" - ], - "type": "string" } }, "required": [ - "object", - "connector_id", - "connector_key", - "connector_modality", - "connection_active", - "deployment_readiness", - "next_step", - "proof_gate", - "runbook_path", - "setup_modality", - "support_state" + "error" ], "type": "object" } } } + } + }, + "summary": "Owner-agent bearer: initiate a new connection as a typed, auditable, owner-mediated intent. Returns the shared setup-plan projection (`setup_modality`, `support_state`, `deployment_readiness`, `proof_gate`, `runbook_path`) plus a typed `next_step`; it never marks a connection active. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", + "tags": [ + "reference", + "connections", + "owner-agent" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "connector_id": { + "minLength": 1, + "type": "string" + }, + "display_name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "connector_id" + ], + "type": "object" + } + } + }, + "required": true + } + } + }, + "/v1/owner/connections/{connectionId}/schedule/pause": { + "post": { + "operationId": "ownerPauseConnectionSchedule", + "parameters": [ + { + "in": "path", + "name": "connectionId", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Paused" }, "400": { "description": "Invalid request", @@ -18525,42 +20589,18 @@ } } }, - "summary": "Owner-agent bearer: initiate a new connection as a typed, auditable, owner-mediated intent. Returns the shared setup-plan projection (`setup_modality`, `support_state`, `deployment_readiness`, `proof_gate`, `runbook_path`) plus a typed `next_step`; it never marks a connection active. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", + "summary": "Owner-agent bearer: pause one configured connection's schedule, addressed by `connection_id`, without deleting its config. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. Shares the controller `setScheduleEnabled` semantics with the cookie-authed `/_ref` pause route under a separate owner-bearer auth adapter.", "tags": [ "reference", + "runs", "connections", "owner-agent" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "connector_id": { - "minLength": 1, - "type": "string" - }, - "display_name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - } - }, - "required": [ - "connector_id" - ], - "type": "object" - } - } - }, - "required": true - } + ] } }, - "/v1/owner/connections/{connectionId}/schedule/pause": { + "/v1/owner/connections/{connectionId}/schedule/resume": { "post": { - "operationId": "ownerPauseConnectionSchedule", + "operationId": "ownerResumeConnectionSchedule", "parameters": [ { "in": "path", @@ -18574,7 +20614,7 @@ ], "responses": { "200": { - "description": "Paused" + "description": "Resumed" }, "400": { "description": "Invalid request", @@ -18841,7 +20881,7 @@ } } }, - "summary": "Owner-agent bearer: pause one configured connection's schedule, addressed by `connection_id`, without deleting its config. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. Shares the controller `setScheduleEnabled` semantics with the cookie-authed `/_ref` pause route under a separate owner-bearer auth adapter.", + "summary": "Owner-agent bearer: resume one paused configured connection's schedule, addressed by `connection_id`. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. Shares the controller `setScheduleEnabled` semantics with the cookie-authed `/_ref` resume route under a separate owner-bearer auth adapter.", "tags": [ "reference", "runs", @@ -18850,13 +20890,13 @@ ] } }, - "/v1/owner/connections/{connectionId}/schedule/resume": { + "/v1/owner/connectors/{connectorId}/schedule/pause": { "post": { - "operationId": "ownerResumeConnectionSchedule", + "operationId": "ownerPauseConnectorSchedule", "parameters": [ { "in": "path", - "name": "connectionId", + "name": "connectorId", "required": true, "schema": { "minLength": 1, @@ -18866,7 +20906,7 @@ ], "responses": { "200": { - "description": "Resumed" + "description": "Paused" }, "400": { "description": "Invalid request", @@ -19133,18 +21173,17 @@ } } }, - "summary": "Owner-agent bearer: resume one paused configured connection's schedule, addressed by `connection_id`. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. Shares the controller `setScheduleEnabled` semantics with the cookie-authed `/_ref` resume route under a separate owner-bearer auth adapter.", + "summary": "Owner-agent bearer: pause a connector's schedule addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", "tags": [ "reference", "runs", - "connections", "owner-agent" ] } }, - "/v1/owner/connectors/{connectorId}/schedule/pause": { + "/v1/owner/connectors/{connectorId}/schedule/resume": { "post": { - "operationId": "ownerPauseConnectorSchedule", + "operationId": "ownerResumeConnectorSchedule", "parameters": [ { "in": "path", @@ -19158,7 +21197,7 @@ ], "responses": { "200": { - "description": "Paused" + "description": "Resumed" }, "400": { "description": "Invalid request", @@ -19425,7 +21464,7 @@ } } }, - "summary": "Owner-agent bearer: pause a connector's schedule addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", + "summary": "Owner-agent bearer: resume a connector's paused schedule addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", "tags": [ "reference", "runs", @@ -19433,13 +21472,13 @@ ] } }, - "/v1/owner/connectors/{connectorId}/schedule/resume": { - "post": { - "operationId": "ownerResumeConnectorSchedule", + "/v1/owner/connections/{connectionId}/schedule": { + "delete": { + "operationId": "ownerDeleteConnectionSchedule", "parameters": [ { "in": "path", - "name": "connectorId", + "name": "connectionId", "required": true, "schema": { "minLength": 1, @@ -19448,8 +21487,8 @@ } ], "responses": { - "200": { - "description": "Resumed" + "204": { + "description": "Schedule deleted" }, "400": { "description": "Invalid request", @@ -19716,21 +21755,22 @@ } } }, - "summary": "Owner-agent bearer: resume a connector's paused schedule addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", + "summary": "Owner-agent bearer: delete one configured connection's schedule config, addressed by `connection_id`. Returns 204 when the schedule was deleted and a typed 404 when no schedule existed. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. Shares the controller `deleteSchedule` semantics with the cookie-authed `/_ref` delete route under a separate owner-bearer auth adapter.", "tags": [ "reference", "runs", + "connections", "owner-agent" ] } }, - "/v1/owner/connections/{connectionId}/schedule": { + "/v1/owner/connectors/{connectorId}/schedule": { "delete": { - "operationId": "ownerDeleteConnectionSchedule", + "operationId": "ownerDeleteConnectorSchedule", "parameters": [ { "in": "path", - "name": "connectionId", + "name": "connectorId", "required": true, "schema": { "minLength": 1, @@ -20007,22 +22047,21 @@ } } }, - "summary": "Owner-agent bearer: delete one configured connection's schedule config, addressed by `connection_id`. Returns 204 when the schedule was deleted and a typed 404 when no schedule existed. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. Shares the controller `deleteSchedule` semantics with the cookie-authed `/_ref` delete route under a separate owner-bearer auth adapter.", + "summary": "Owner-agent bearer: delete a connector's schedule config addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Returns 204 on delete and a typed 404 when no schedule existed. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", "tags": [ "reference", "runs", - "connections", "owner-agent" ] } }, - "/v1/owner/connectors/{connectorId}/schedule": { - "delete": { - "operationId": "ownerDeleteConnectorSchedule", + "/v1/owner/connections/{connectionId}/run": { + "post": { + "operationId": "ownerRunConnection", "parameters": [ { "in": "path", - "name": "connectorId", + "name": "connectionId", "required": true, "schema": { "minLength": 1, @@ -20031,8 +22070,27 @@ } ], "responses": { - "204": { - "description": "Schedule deleted" + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "run_id": { + "type": "string" + }, + "trace_id": { + "type": "string" + } + }, + "required": [ + "run_id" + ], + "type": "object" + } + } + } }, "400": { "description": "Invalid request", @@ -20299,21 +22357,22 @@ } } }, - "summary": "Owner-agent bearer: delete a connector's schedule config addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Returns 204 on delete and a typed 404 when no schedule existed. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", + "summary": "Owner-agent bearer: start a run-now for one configured connection, addressed by `connection_id`. Returns 202 with run_id + trace_id, or 409 run_already_active. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. Shares the controller `runNow` semantics with the cookie-authed `/_ref` run route under a separate owner-bearer auth adapter.", "tags": [ "reference", "runs", + "connections", "owner-agent" ] } }, - "/v1/owner/connections/{connectionId}/run": { + "/v1/owner/connectors/{connectorId}/run": { "post": { - "operationId": "ownerRunConnection", + "operationId": "ownerRunConnector", "parameters": [ { "in": "path", - "name": "connectionId", + "name": "connectorId", "required": true, "schema": { "minLength": 1, @@ -20609,22 +22668,21 @@ } } }, - "summary": "Owner-agent bearer: start a run-now for one configured connection, addressed by `connection_id`. Returns 202 with run_id + trace_id, or 409 run_already_active. Owner bearers only; client/mcp_package grants SHALL NOT reach this route. Shares the controller `runNow` semantics with the cookie-authed `/_ref` run route under a separate owner-bearer auth adapter.", + "summary": "Owner-agent bearer: start a run-now for a connector addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Returns 202 with run_id + trace_id, or 409 run_already_active. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", "tags": [ "reference", "runs", - "connections", "owner-agent" ] } }, - "/v1/owner/connectors/{connectorId}/run": { + "/v1/owner/connections/{connectionId}/revoke": { "post": { - "operationId": "ownerRunConnector", + "operationId": "ownerRevokeConnection", "parameters": [ { "in": "path", - "name": "connectorId", + "name": "connectionId", "required": true, "schema": { "minLength": 1, @@ -20633,22 +22691,42 @@ } ], "responses": { - "202": { - "description": "Accepted", + "200": { + "description": "Revoked", "content": { "application/json": { "schema": { - "additionalProperties": true, + "additionalProperties": false, "properties": { - "run_id": { + "connection_id": { "type": "string" }, - "trace_id": { + "connector_id": { + "type": "string" + }, + "connector_key": { "type": "string" + }, + "object": { + "const": "owner_connection_revoke" + }, + "revoked_at": { + "type": [ + "string", + "null" + ] + }, + "status": { + "const": "revoked" } }, "required": [ - "run_id" + "object", + "connection_id", + "connector_id", + "connector_key", + "status", + "revoked_at" ], "type": "object" } @@ -20920,21 +22998,21 @@ } } }, - "summary": "Owner-agent bearer: start a run-now for a connector addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Returns 202 with run_id + trace_id, or 409 run_already_active. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", + "summary": "Owner-agent bearer: revoke one configured connection, addressed by `connection_id`. Flips the connection to status `revoked` so no future run/ingest lands; already-collected records, spine evidence, device rows, and sibling connections are untouched (zero cascade), and the revoke is durable across owner reads and grant/polyfill scope resolution. A double-revoke returns a typed `connector_instance_inactive` (400). Owner bearers only; client/mcp_package grants SHALL NOT reach this route. `/mcp` owner-bearer rejection is untouched.", "tags": [ "reference", - "runs", + "connections", "owner-agent" ] } }, - "/v1/owner/connections/{connectionId}/revoke": { + "/v1/owner/connectors/{connectorId}/revoke": { "post": { - "operationId": "ownerRevokeConnection", + "operationId": "ownerRevokeConnector", "parameters": [ { "in": "path", - "name": "connectionId", + "name": "connectorId", "required": true, "schema": { "minLength": 1, @@ -21250,21 +23328,20 @@ } } }, - "summary": "Owner-agent bearer: revoke one configured connection, addressed by `connection_id`. Flips the connection to status `revoked` so no future run/ingest lands; already-collected records, spine evidence, device rows, and sibling connections are untouched (zero cascade), and the revoke is durable across owner reads and grant/polyfill scope resolution. A double-revoke returns a typed `connector_instance_inactive` (400). Owner bearers only; client/mcp_package grants SHALL NOT reach this route. `/mcp` owner-bearer rejection is untouched.", + "summary": "Owner-agent bearer: revoke a connector's connection addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Flips the resolved connection to status `revoked` (zero cascade, durable). Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", "tags": [ "reference", - "connections", "owner-agent" ] } }, - "/v1/owner/connectors/{connectorId}/revoke": { + "/v1/owner/connections/{connectionId}/reactivate": { "post": { - "operationId": "ownerRevokeConnector", + "operationId": "ownerReactivateConnection", "parameters": [ { "in": "path", - "name": "connectorId", + "name": "connectionId", "required": true, "schema": { "minLength": 1, @@ -21274,7 +23351,7 @@ ], "responses": { "200": { - "description": "Revoked", + "description": "Reactivated", "content": { "application/json": { "schema": { @@ -21290,16 +23367,13 @@ "type": "string" }, "object": { - "const": "owner_connection_revoke" + "const": "owner_connection_reactivate" }, - "revoked_at": { - "type": [ - "string", - "null" - ] + "reactivated_at": { + "type": "string" }, "status": { - "const": "revoked" + "const": "active" } }, "required": [ @@ -21308,7 +23382,7 @@ "connector_id", "connector_key", "status", - "revoked_at" + "reactivated_at" ], "type": "object" } @@ -21580,20 +23654,21 @@ } } }, - "summary": "Owner-agent bearer: revoke a connector's connection addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Flips the resolved connection to status `revoked` (zero cascade, durable). Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", + "summary": "Owner-agent bearer: reactivate one revoked connection, addressed by `connection_id`. The clean inverse of `ownerRevokeConnection`: flips the connection from `revoked` back to `active`, clears `revoked_at`, and resumes future collection. Already-collected records, grants, schedule, and audit spine are untouched (zero cascade). A non-revoked (active/draft) connection returns `connector_instance_not_revoked` (409). A foreign/unknown id returns `connector_instance_not_found` (404). Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", "tags": [ "reference", + "connections", "owner-agent" ] } }, - "/v1/owner/connections/{connectionId}/reactivate": { + "/v1/owner/connectors/{connectorId}/reactivate": { "post": { - "operationId": "ownerReactivateConnection", + "operationId": "ownerReactivateConnector", "parameters": [ { "in": "path", - "name": "connectionId", + "name": "connectorId", "required": true, "schema": { "minLength": 1, @@ -21904,72 +23979,171 @@ } } } - } - }, - "summary": "Owner-agent bearer: reactivate one revoked connection, addressed by `connection_id`. The clean inverse of `ownerRevokeConnection`: flips the connection from `revoked` back to `active`, clears `revoked_at`, and resumes future collection. Already-collected records, grants, schedule, and audit spine are untouched (zero cascade). A non-revoked (active/draft) connection returns `connector_instance_not_revoked` (409). A foreign/unknown id returns `connector_instance_not_found` (404). Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", - "tags": [ - "reference", - "connections", - "owner-agent" - ] - } - }, - "/v1/owner/connectors/{connectorId}/reactivate": { - "post": { - "operationId": "ownerReactivateConnector", - "parameters": [ - { - "in": "path", - "name": "connectorId", - "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Reactivated", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "connection_id": { - "type": "string" - }, - "connector_id": { - "type": "string" - }, - "connector_key": { - "type": "string" - }, - "object": { - "const": "owner_connection_reactivate" - }, - "reactivated_at": { - "type": "string" - }, - "status": { - "const": "active" - } - }, - "required": [ - "object", - "connection_id", - "connector_id", - "connector_key", - "status", - "reactivated_at" - ], - "type": "object" - } - } - } + } + }, + "summary": "Owner-agent bearer: reactivate a connector's revoked connection addressed by `connector_id`. Auto-selects the only revoked connection for that connector. When more than one connection exists the request is rejected with a typed `ambiguous_connection` (409). Flips the resolved connection from `revoked` to `active` (zero cascade). Owner bearers only.", + "tags": [ + "reference", + "owner-agent" + ] + } + }, + "/v1/owner/connectors/{connectorId}": { + "delete": { + "operationId": "ownerDeleteConnector", + "parameters": [ + { + "in": "path", + "name": "connectorId", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deleted", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "deleted": { + "const": true + }, + "deleted_record_count": { + "type": "integer" + }, + "deleted_stream_count": { + "type": "integer" + }, + "device_refs_cleared": { + "type": "integer" + }, + "object": { + "const": "owner_connection_delete" + }, + "schedule_deleted": { + "type": "boolean" + } + }, + "required": [ + "object", + "connection_id", + "connector_id", + "connector_key", + "deleted", + "deleted_record_count", + "deleted_stream_count", + "schedule_deleted", + "device_refs_cleared" + ], + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } }, - "400": { - "description": "Invalid request", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -22056,8 +24230,8 @@ } } }, - "404": { - "description": "Not found", + "409": { + "description": "Conflict (e.g. run_already_active)", "content": { "application/json": { "schema": { @@ -22143,163 +24317,350 @@ } } } - }, - "409": { - "description": "Conflict (e.g. run_already_active)", + } + }, + "summary": "Owner-agent bearer: DESTRUCTIVELY delete a connector's connection addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Erases the resolved connection's data + configuration per the connection-scoped cascade (see ownerDeleteConnection). Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", + "tags": [ + "reference", + "owner-agent" + ] + } + }, + "/v1/owner/connections/{connectionId}/diagnostics": { + "get": { + "operationId": "ownerInspectConnectionDiagnostics", + "parameters": [ + { + "in": "path", + "name": "connectionId", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", "content": { "application/json": { "schema": { - "$id": "pdpp/common/PdppError", "additionalProperties": false, "properties": { - "error": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "freshness": { + "additionalProperties": true, + "type": "object" + }, + "health": { + "additionalProperties": false, + "properties": { + "axes": { + "additionalProperties": true, + "type": "object" + }, + "badges": { + "additionalProperties": true, + "type": "object" + }, + "last_success_at": { + "type": [ + "string", + "null" + ] + }, + "next_attempt_at": { + "type": [ + "string", + "null" + ] + }, + "reason_code": { + "type": [ + "string", + "null" + ] + }, + "state": { + "enum": [ + "blocked", + "cooling_off", + "degraded", + "healthy", + "idle", + "needs_attention", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "state", + "reason_code", + "last_success_at", + "next_attempt_at", + "axes", + "badges" + ], + "type": "object" + }, + "last_ingest_at": { + "type": [ + "string", + "null" + ] + }, + "last_run": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "failure_reason": { + "type": [ + "string", + "null" + ] + }, + "finished_at": { + "type": [ + "string", + "null" + ] + }, + "run_id": { + "type": [ + "string", + "null" + ] + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + } + }, + "required": [ + "run_id", + "status", + "started_at", + "finished_at", + "failure_reason" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "last_successful_run": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "failure_reason": { + "type": [ + "string", + "null" + ] + }, + "finished_at": { + "type": [ + "string", + "null" + ] + }, + "run_id": { + "type": [ + "string", + "null" + ] + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + } + }, + "required": [ + "run_id", + "status", + "started_at", + "finished_at", + "failure_reason" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "object": { + "const": "owner_connection_diagnostics" + }, + "recovery": { "additionalProperties": false, "properties": { - "available_connections": { - "items": { - "$id": "pdpp/common/ErrorAvailableConnection", - "additionalProperties": false, - "properties": { - "connection_id": { - "type": "string" - }, - "connector_id": { - "type": "string" - }, - "connector_key": { - "type": "string" - }, - "display_name": { - "type": [ - "string", - "null" - ] + "admission": { + "additionalProperties": false, + "properties": { + "admitted": { + "type": "integer" + }, + "candidates": { + "type": "integer" + }, + "deferred": { + "type": "integer" + }, + "deferred_by_reason": { + "additionalProperties": false, + "properties": { + "budget": { + "type": "integer" + }, + "cooldown": { + "type": "integer" + }, + "owner_required": { + "type": "integer" + }, + "system_issue": { + "type": "integer" + } }, - "label_status": { - "enum": [ - "owner_set", - "fallback" - ], - "type": "string" - } + "type": "object" }, - "required": [ - "connection_id" - ], - "type": "object" + "next_eligible_at": { + "type": "string" + }, + "why_not_now": { + "enum": [ + "cooldown", + "budget", + "owner_required", + "system_issue" + ], + "type": "string" + } }, - "type": "array" - }, - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "next_step": { - "type": "string" - }, - "param": { - "type": "string" - }, - "request_id": { - "type": "string" + "required": [ + "candidates", + "admitted", + "deferred" + ], + "type": "object" }, - "resource_metadata": { - "type": "string" + "read_limit": { + "type": [ + "integer", + "null" + ] }, - "retry_with": { - "type": "string" + "stall": { + "additionalProperties": false, + "properties": { + "eligibleCandidates": { + "type": "integer" + }, + "lastAttemptAt": { + "type": [ + "string", + "null" + ] + }, + "stalled": { + "type": "boolean" + } + }, + "required": [ + "stalled", + "eligibleCandidates", + "lastAttemptAt" + ], + "type": "object" }, - "type": { - "type": "string" + "unreadable": { + "type": "boolean" } }, "required": [ - "type", - "code", - "message", - "request_id" + "admission", + "stall", + "read_limit", + "unreadable" ], "type": "object" - } - }, - "required": [ - "error" - ], - "type": "object" - } - } - } - } - }, - "summary": "Owner-agent bearer: reactivate a connector's revoked connection addressed by `connector_id`. Auto-selects the only revoked connection for that connector. When more than one connection exists the request is rejected with a typed `ambiguous_connection` (409). Flips the resolved connection from `revoked` to `active` (zero cascade). Owner bearers only.", - "tags": [ - "reference", - "owner-agent" - ] - } - }, - "/v1/owner/connectors/{connectorId}": { - "delete": { - "operationId": "ownerDeleteConnector", - "parameters": [ - { - "in": "path", - "name": "connectorId", - "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Deleted", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "connection_id": { - "type": "string" - }, - "connector_id": { - "type": "string" - }, - "connector_key": { - "type": "string" - }, - "deleted": { - "const": true - }, - "deleted_record_count": { - "type": "integer" - }, - "deleted_stream_count": { - "type": "integer" - }, - "device_refs_cleared": { - "type": "integer" }, - "object": { - "const": "owner_connection_delete" + "rendered_verdict": { + "additionalProperties": true, + "type": "object" }, - "schedule_deleted": { - "type": "boolean" + "schedule": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "interval_seconds": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "enabled", + "interval_seconds" + ], + "type": "object" + }, + { + "type": "null" + } + ] } }, "required": [ "object", "connection_id", "connector_id", - "connector_key", - "deleted", - "deleted_record_count", - "deleted_stream_count", - "schedule_deleted", - "device_refs_cleared" + "connector_key", + "display_name", + "health", + "last_run", + "last_successful_run", + "last_ingest_at", + "schedule", + "freshness", + "recovery", + "rendered_verdict" ], "type": "object" } @@ -22571,20 +24932,21 @@ } } }, - "summary": "Owner-agent bearer: DESTRUCTIVELY delete a connector's connection addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Erases the resolved connection's data + configuration per the connection-scoped cascade (see ownerDeleteConnection). Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", + "summary": "Owner-agent bearer: read connection-scoped diagnostics for one configured connection, addressed by `connection_id` — last run status, last successful run, last successful ingest time, current schedule state, freshness, and a typed health classification. Connection-scoped by construction: the response describes only the addressed connection and carries no device-exporter subsystem or sibling-connection state. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", "tags": [ "reference", + "connections", "owner-agent" ] } }, - "/v1/owner/connections/{connectionId}/diagnostics": { + "/v1/owner/connectors/{connectorId}/diagnostics": { "get": { - "operationId": "ownerInspectConnectionDiagnostics", + "operationId": "ownerInspectConnectorDiagnostics", "parameters": [ { "in": "path", - "name": "connectionId", + "name": "connectorId", "required": true, "schema": { "minLength": 1, @@ -23184,7 +25546,7 @@ } } }, - "summary": "Owner-agent bearer: read connection-scoped diagnostics for one configured connection, addressed by `connection_id` — last run status, last successful run, last successful ingest time, current schedule state, freshness, and a typed health classification. Connection-scoped by construction: the response describes only the addressed connection and carries no device-exporter subsystem or sibling-connection state. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", + "summary": "Owner-agent bearer: read connection-scoped diagnostics for a connector addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", "tags": [ "reference", "connections", @@ -23192,13 +25554,13 @@ ] } }, - "/v1/owner/connectors/{connectorId}/diagnostics": { + "/_ref/connections/{connectorInstanceId}": { "get": { - "operationId": "ownerInspectConnectorDiagnostics", + "operationId": "refGetConnection", "parameters": [ { "in": "path", - "name": "connectorId", + "name": "connectorInstanceId", "required": true, "schema": { "minLength": 1, @@ -23212,15 +25574,15 @@ "content": { "application/json": { "schema": { - "additionalProperties": false, + "additionalProperties": true, "properties": { - "connection_id": { + "connector_id": { "type": "string" }, - "connector_id": { + "connector_instance_id": { "type": "string" }, - "connector_key": { + "created_at": { "type": "string" }, "display_name": { @@ -23229,304 +25591,415 @@ "null" ] }, - "freshness": { + "object": { + "const": "ref_connection" + }, + "revoked_at": { + "type": [ + "string", + "null" + ] + }, + "schedule": { "additionalProperties": true, - "type": "object" + "type": [ + "object", + "null" + ] }, - "health": { + "source_binding": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "source_kind": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "object", + "connector_instance_id", + "connector_id", + "display_name", + "status", + "source_kind", + "source_binding", + "created_at", + "updated_at", + "revoked_at", + "schedule" + ], + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { "additionalProperties": false, "properties": { - "axes": { - "additionalProperties": true, - "type": "object" + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" }, - "badges": { - "additionalProperties": true, - "type": "object" + "code": { + "type": "string" }, - "last_success_at": { - "type": [ - "string", - "null" - ] + "message": { + "type": "string" }, - "next_attempt_at": { - "type": [ - "string", - "null" - ] + "next_step": { + "type": "string" }, - "reason_code": { - "type": [ - "string", - "null" - ] + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" }, - "state": { - "enum": [ - "blocked", - "cooling_off", - "degraded", - "healthy", - "idle", - "needs_attention", - "unknown" - ], + "retry_with": { + "type": "string" + }, + "type": { "type": "string" } }, "required": [ - "state", - "reason_code", - "last_success_at", - "next_attempt_at", - "axes", - "badges" + "type", + "code", + "message", + "request_id" ], "type": "object" - }, - "last_ingest_at": { - "type": [ - "string", - "null" - ] - }, - "last_run": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "failure_reason": { - "type": [ - "string", - "null" - ] - }, - "finished_at": { - "type": [ - "string", - "null" - ] - }, - "run_id": { - "type": [ - "string", - "null" - ] - }, - "started_at": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - } - }, - "required": [ - "run_id", - "status", - "started_at", - "finished_at", - "failure_reason" - ], - "type": "object" - }, - { - "type": "null" - } - ] - }, - "last_successful_run": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "failure_reason": { - "type": [ - "string", - "null" - ] - }, - "finished_at": { - "type": [ - "string", - "null" - ] - }, - "run_id": { - "type": [ - "string", - "null" - ] - }, - "started_at": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - } - }, - "required": [ - "run_id", - "status", - "started_at", - "finished_at", - "failure_reason" - ], - "type": "object" - }, - { - "type": "null" - } - ] - }, - "object": { - "const": "owner_connection_diagnostics" - }, - "recovery": { + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "Conflict (e.g. run_already_active)", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { "additionalProperties": false, "properties": { - "admission": { - "additionalProperties": false, - "properties": { - "admitted": { - "type": "integer" - }, - "candidates": { - "type": "integer" - }, - "deferred": { - "type": "integer" - }, - "deferred_by_reason": { - "additionalProperties": false, - "properties": { - "budget": { - "type": "integer" - }, - "cooldown": { - "type": "integer" - }, - "owner_required": { - "type": "integer" - }, - "system_issue": { - "type": "integer" - } + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" }, - "type": "object" - }, - "next_eligible_at": { - "type": "string" + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } }, - "why_not_now": { - "enum": [ - "cooldown", - "budget", - "owner_required", - "system_issue" - ], - "type": "string" - } + "required": [ + "connection_id" + ], + "type": "object" }, - "required": [ - "candidates", - "admitted", - "deferred" - ], - "type": "object" + "type": "array" }, - "read_limit": { - "type": [ - "integer", - "null" - ] + "code": { + "type": "string" }, - "stall": { - "additionalProperties": false, - "properties": { - "eligibleCandidates": { - "type": "integer" - }, - "lastAttemptAt": { - "type": [ - "string", - "null" - ] - }, - "stalled": { - "type": "boolean" - } - }, - "required": [ - "stalled", - "eligibleCandidates", - "lastAttemptAt" - ], - "type": "object" + "message": { + "type": "string" }, - "unreadable": { - "type": "boolean" + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" } }, "required": [ - "admission", - "stall", - "read_limit", - "unreadable" + "type", + "code", + "message", + "request_id" ], "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + } + }, + "summary": "Get one owner-facing configured connector connection by connector instance id.", + "tags": [ + "reference", + "connections" + ] + }, + "patch": { + "operationId": "refSetConnectionDisplayName", + "parameters": [ + { + "in": "path", + "name": "connectorInstanceId", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "properties": { + "connector_id": { + "type": "string" }, - "rendered_verdict": { - "additionalProperties": true, - "type": "object" + "connector_instance_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "object": { + "const": "ref_connection" + }, + "revoked_at": { + "type": [ + "string", + "null" + ] }, "schedule": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean" - }, - "interval_seconds": { - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "enabled", - "interval_seconds" - ], - "type": "object" - }, - { - "type": "null" - } + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "source_binding": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "source_kind": { + "type": [ + "string", + "null" ] + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" } }, "required": [ "object", - "connection_id", + "connector_instance_id", "connector_id", - "connector_key", "display_name", - "health", - "last_run", - "last_successful_run", - "last_ingest_at", - "schedule", - "freshness", - "recovery", - "rendered_verdict" + "status", + "source_kind", + "source_binding", + "created_at", + "updated_at", + "revoked_at", + "schedule" ], "type": "object" } @@ -23798,105 +26271,50 @@ } } }, - "summary": "Owner-agent bearer: read connection-scoped diagnostics for a connector addressed by `connector_id`. Auto-selects the only active connection for that connector. When more than one active connection exists the request is rejected with a typed `ambiguous_connection` (409) carrying the available `connection_id` values and `retry_with: connection_id`. Owner bearers only; client/mcp_package grants SHALL NOT reach this route.", + "summary": "Owner-authenticated mutation of the owner-meaningful `display_name` carried on the public read contract. Operator-only surface; grant-authorized tokens SHALL NOT reach this route.", "tags": [ "reference", - "connections", - "owner-agent" - ] - } - }, - "/_ref/connections/{connectorInstanceId}": { - "get": { - "operationId": "refGetConnection", - "parameters": [ - { - "in": "path", - "name": "connectorInstanceId", - "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - } + "connections" ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "properties": { - "connector_id": { - "type": "string" - }, - "connector_instance_id": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "object": { - "const": "ref_connection" - }, - "revoked_at": { - "type": [ - "string", - "null" - ] - }, - "schedule": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "source_binding": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "source_kind": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - }, - "required": [ - "object", - "connector_instance_id", - "connector_id", - "display_name", - "status", - "source_kind", - "source_binding", - "created_at", - "updated_at", - "revoked_at", - "schedule" - ], - "type": "object" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "display_name": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "display_name" + ], + "type": "object" } } }, + "required": true + } + }, + "delete": { + "operationId": "refDeleteConnection", + "parameters": [ + { + "in": "path", + "name": "connectorInstanceId", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deleted" + }, "400": { "description": "Invalid request", "content": { @@ -24162,14 +26580,16 @@ } } }, - "summary": "Get one owner-facing configured connector connection by connector instance id.", + "summary": "Owner-session: DESTRUCTIVELY delete one configured connection, addressed by `connection_id`. Erases exactly that connection's records, history, blobs, search indices, and attention, deletes its schedule, clears its device source-instance back-reference, and removes the connector_instances row — keyed strictly on one connection_id, never widening to connector_id (sibling connections untouched). A connection with an in-flight run is REFUSED (`connection_run_active` 409), and a default-account binding is REFUSED (`default_account_delete_unsupported` 409). A repeat/unknown/foreign-owner id returns a typed `connector_instance_not_found` (404). PRESERVES the audit spine (appending an owner_agent.connection.delete event), disclosure grants, and the device edge. Owner-session only (operator console); shares the same `deleteConnection` cascade and audit event type as the owner-agent bearer `ownerDeleteConnection` route under a cookie auth adapter.", "tags": [ "reference", "connections" ] - }, - "patch": { - "operationId": "refSetConnectionDisplayName", + } + }, + "/_ref/connector-instances/{connectorInstanceId}": { + "get": { + "operationId": "refGetConnectorInstance", "parameters": [ { "in": "path", @@ -24523,49 +26943,76 @@ } } }, - "summary": "Owner-authenticated mutation of the owner-meaningful `display_name` carried on the public read contract. Operator-only surface; grant-authorized tokens SHALL NOT reach this route.", + "summary": "Compatibility alias for reading one configured connector instance behind an owner-facing connection.", "tags": [ "reference", "connections" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "display_name": { - "maxLength": 200, - "minLength": 1, - "type": "string" - } - }, - "required": [ - "display_name" - ], - "type": "object" - } - } - }, - "required": true - } - }, - "delete": { - "operationId": "refDeleteConnection", - "parameters": [ - { - "in": "path", - "name": "connectorInstanceId", - "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - } - ], + ] + } + }, + "/_ref/approvals": { + "get": { + "operationId": "refListApprovals", + "parameters": [], "responses": { "200": { - "description": "Deleted" + "description": "", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "additionalProperties": true, + "properties": { + "approval_id": { + "type": "string" + }, + "client_id": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "grant_preview": { + "type": "object" + }, + "kind": { + "enum": [ + "consent", + "owner_device" + ], + "type": "string" + }, + "object": { + "const": "approval" + } + }, + "required": [ + "object", + "approval_id", + "kind" + ], + "type": "object" + }, + "type": "array" + }, + "object": { + "const": "list" + } + }, + "required": [ + "object", + "data" + ], + "type": "object" + } + } + } }, "400": { "description": "Invalid request", @@ -24796,470 +27243,2578 @@ "type": "string" }, "next_step": { - "type": "string" - }, - "param": { - "type": "string" - }, - "request_id": { - "type": "string" - }, - "resource_metadata": { - "type": "string" - }, - "retry_with": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "type", - "code", - "message", - "request_id" - ], - "type": "object" - } - }, - "required": [ - "error" - ], - "type": "object" - } - } - } - } - }, - "summary": "Owner-session: DESTRUCTIVELY delete one configured connection, addressed by `connection_id`. Erases exactly that connection's records, history, blobs, search indices, and attention, deletes its schedule, clears its device source-instance back-reference, and removes the connector_instances row — keyed strictly on one connection_id, never widening to connector_id (sibling connections untouched). A connection with an in-flight run is REFUSED (`connection_run_active` 409), and a default-account binding is REFUSED (`default_account_delete_unsupported` 409). A repeat/unknown/foreign-owner id returns a typed `connector_instance_not_found` (404). PRESERVES the audit spine (appending an owner_agent.connection.delete event), disclosure grants, and the device edge. Owner-session only (operator console); shares the same `deleteConnection` cascade and audit event type as the owner-agent bearer `ownerDeleteConnection` route under a cookie auth adapter.", - "tags": [ - "reference", - "connections" - ] - } - }, - "/_ref/connector-instances/{connectorInstanceId}": { - "get": { - "operationId": "refGetConnectorInstance", - "parameters": [ - { - "in": "path", - "name": "connectorInstanceId", - "required": true, - "schema": { - "minLength": 1, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "properties": { - "connector_id": { - "type": "string" - }, - "connector_instance_id": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "object": { - "const": "ref_connection" - }, - "revoked_at": { - "type": [ - "string", - "null" - ] - }, - "schedule": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "source_binding": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "source_kind": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - }, - "updated_at": { - "type": "string" + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" } }, "required": [ - "object", - "connector_instance_id", - "connector_id", - "display_name", - "status", - "source_kind", - "source_binding", - "created_at", - "updated_at", - "revoked_at", - "schedule" + "error" ], "type": "object" } } } - }, - "400": { - "description": "Invalid request", + } + }, + "summary": "List pending approvals across provider-connect consents and owner-device flows.", + "tags": [ + "reference", + "grants" + ] + } + }, + "/_ref/approvals/{approvalId}": { + "get": { + "operationId": "refGetApproval", + "parameters": [ + { + "in": "path", + "name": "approvalId", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", "content": { "application/json": { "schema": { - "$id": "pdpp/common/PdppError", - "additionalProperties": false, - "properties": { - "error": { + "oneOf": [ + { "additionalProperties": false, "properties": { - "available_connections": { + "approval_id": { + "type": "string" + }, + "client": { + "additionalProperties": false, + "properties": { + "client_id": { + "type": "string" + }, + "display": { + "additionalProperties": false, + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "policy_uri": { + "type": [ + "string", + "null" + ] + }, + "tos_uri": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "policy_uri", + "tos_uri", + "uri" + ], + "type": "object" + }, + "registration_mode": { + "type": "string" + } + }, + "required": [ + "client_id", + "display", + "registration_mode" + ], + "type": "object" + }, + "created_at": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "grant_outcome": { + "additionalProperties": false, + "properties": { + "access_mode": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "access_mode", + "description" + ], + "type": "object" + }, + "kind": { + "const": "consent" + }, + "object": { + "const": "approval_review" + }, + "purpose": { + "additionalProperties": false, + "properties": { + "code": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "code", + "description" + ], + "type": "object" + }, + "retention": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "source": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "kind": { + "enum": [ + "connector", + "provider_native" + ], + "type": "string" + } + }, + "required": [ + "id", + "kind" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "streams": { "items": { - "$id": "pdpp/common/ErrorAvailableConnection", "additionalProperties": false, "properties": { + "client_claims": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, "connection_id": { - "type": "string" + "type": [ + "string", + "null" + ] }, - "connector_id": { - "type": "string" + "fields": { + "oneOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] }, - "connector_key": { + "name": { "type": "string" }, - "display_name": { + "necessity": { "type": [ "string", "null" ] }, - "label_status": { - "enum": [ - "owner_set", - "fallback" - ], - "type": "string" - } - }, - "required": [ - "connection_id" - ], - "type": "object" - }, - "type": "array" - }, - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "next_step": { - "type": "string" - }, - "param": { - "type": "string" - }, - "request_id": { - "type": "string" - }, - "resource_metadata": { - "type": "string" - }, - "retry_with": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "type", - "code", - "message", - "request_id" - ], - "type": "object" - } - }, - "required": [ - "error" - ], - "type": "object" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$id": "pdpp/common/PdppError", - "additionalProperties": false, - "properties": { - "error": { - "additionalProperties": false, - "properties": { - "available_connections": { - "items": { - "$id": "pdpp/common/ErrorAvailableConnection", - "additionalProperties": false, - "properties": { - "connection_id": { - "type": "string" - }, - "connector_id": { - "type": "string" + "resources": { + "oneOf": [ + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "additionalProperties": { + "oneOf": [ + { + "type": [ + "boolean", + "null", + "number", + "string" + ] + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "propertyNames": { + "not": { + "pattern": "^(?:[Aa][Cc][Cc][Ee][Ss][Ss][_-]?[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Aa][Uu][Tt][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr][_-]?[Tt][Oo][Kk][Ee][Nn]|[Cc][Ll][Ii][Ee][Nn][Tt](?:[_-]?[Ss][Ee][Cc][Rr][Ee][Tt]|[Ss][Ee][Cc][Rr][Ee][Tt])|[Dd][Ee][Vv][Ii][Cc][Ee][_-]?[Cc][Oo][Dd][Ee]|[Ii][Dd][_-]?[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Rr][Aa][Mm][Ss][_-]?[Jj][Ss][Oo][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Rr][Ee][Ff][Rr][Ee][Ss][Hh][_-]?[Tt][Oo][Kk][Ee][Nn]|[Rr][Ee][Qq][Uu][Ee][Ss][Tt][_-]?[Uu][Rr][Ii]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Uu][Ss][Ee][Rr][_-]?[Cc][Oo][Dd][Ee])$" + } + }, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ] }, - "connector_key": { - "type": "string" + "time_range": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "since": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "since" + ], + "type": "object" + }, + { + "type": "null" + } + ] }, - "display_name": { + "view": { "type": [ "string", "null" ] - }, - "label_status": { - "enum": [ - "owner_set", - "fallback" - ], - "type": "string" } }, "required": [ - "connection_id" + "client_claims", + "connection_id", + "fields", + "name", + "necessity", + "resources", + "time_range", + "view" ], "type": "object" }, "type": "array" }, - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "next_step": { - "type": "string" - }, - "param": { - "type": "string" - }, - "request_id": { - "type": "string" - }, - "resource_metadata": { - "type": "string" - }, - "retry_with": { - "type": "string" - }, - "type": { - "type": "string" + "trust": { + "const": "unverified" } }, "required": [ - "type", - "code", - "message", - "request_id" + "object", + "approval_id", + "client", + "created_at", + "expires_at", + "grant_outcome", + "kind", + "purpose", + "retention", + "source", + "streams", + "trust" ], "type": "object" - } - }, - "required": [ - "error" - ], - "type": "object" - } - } - } - }, - "409": { - "description": "Conflict (e.g. run_already_active)", - "content": { - "application/json": { - "schema": { - "$id": "pdpp/common/PdppError", - "additionalProperties": false, - "properties": { - "error": { + }, + { "additionalProperties": false, "properties": { - "available_connections": { - "items": { - "$id": "pdpp/common/ErrorAvailableConnection", - "additionalProperties": false, - "properties": { - "connection_id": { - "type": "string" - }, - "connector_id": { - "type": "string" - }, - "connector_key": { - "type": "string" - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "label_status": { - "enum": [ - "owner_set", - "fallback" - ], - "type": "string" - } - }, - "required": [ - "connection_id" - ], - "type": "object" - }, - "type": "array" - }, - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "next_step": { + "approval_id": { "type": "string" }, - "param": { + "client_id": { "type": "string" }, - "request_id": { + "created_at": { "type": "string" }, - "resource_metadata": { + "expires_at": { "type": "string" }, - "retry_with": { - "type": "string" + "kind": { + "const": "owner_device" }, - "type": { - "type": "string" + "object": { + "const": "approval_review" } }, "required": [ - "type", - "code", - "message", - "request_id" + "object", + "approval_id", + "client_id", + "kind", + "created_at", + "expires_at" ], "type": "object" } - }, - "required": [ - "error" - ], - "type": "object" - } - } - } - } - }, - "summary": "Compatibility alias for reading one configured connector instance behind an owner-facing connection.", - "tags": [ - "reference", - "connections" - ] - } - }, - "/_ref/approvals": { - "get": { - "operationId": "refListApprovals", - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "data": { - "items": { - "additionalProperties": true, - "properties": { - "approval_id": { - "type": "string" - }, - "client_id": { - "type": [ - "string", - "null" - ] - }, - "created_at": { - "type": "string" - }, - "grant_preview": { - "type": "object" - }, - "kind": { - "enum": [ - "consent", - "owner_device" - ], - "type": "string" - }, - "object": { - "const": "approval" - } - }, - "required": [ - "object", - "approval_id", - "kind" - ], - "type": "object" - }, - "type": "array" - }, - "object": { - "const": "list" - } - }, - "required": [ - "object", - "data" ], "type": "object" } @@ -25531,7 +30086,7 @@ } } }, - "summary": "List pending approvals across provider-connect consents and owner-device flows.", + "summary": "Get one pending approval review by opaque approval_id. The reference projection excludes device-flow credentials and raw persisted request payloads.", "tags": [ "reference", "grants" diff --git a/reference-implementation/openapi/reference-public.openapi.json b/reference-implementation/openapi/reference-public.openapi.json index e60cbbb93..2b7b734b6 100644 --- a/reference-implementation/openapi/reference-public.openapi.json +++ b/reference-implementation/openapi/reference-public.openapi.json @@ -965,6 +965,11 @@ "pdpp_self_export_supported": { "type": "boolean" }, + "pdpp_source_declaration_uri": { + "format": "uri", + "pattern": "^(?!.*[\\p{Cc}\\s\\\\#])(?!.*%(?![0-9A-Fa-f]{2}))[Hh][Tt][Tt][Pp][Ss]:\\/\\/(?![^/?#]*@)(?:\\[[0-9A-Fa-f:.]+\\](?::\\d+)?|[^/?#\\s\\\\@:%]+(?::\\d+)?)(?:[/?][^\\s\\\\#]*)?$", + "type": "string" + }, "pdpp_token_kinds_supported": { "items": { "enum": [ @@ -1001,7 +1006,7 @@ } } }, - "summary": "Return RFC 9728 protected-resource metadata advertising the PDPP query base, owner-self-export, advisory `pdpp_agent_discovery` / `pdpp_owner_agent_onboarding` when safely configured, and capabilities such as `client_event_subscriptions`.", + "summary": "Return RFC 9728 protected-resource metadata advertising the optional provider-native `pdpp_source_declaration_uri`, the PDPP query base, owner-self-export, advisory `pdpp_agent_discovery` / `pdpp_owner_agent_onboarding` when safely configured, and capabilities such as `client_event_subscriptions`.", "tags": [ "metadata" ] @@ -1586,6 +1591,11 @@ "pdpp_self_export_supported": { "type": "boolean" }, + "pdpp_source_declaration_uri": { + "format": "uri", + "pattern": "^(?!.*[\\p{Cc}\\s\\\\#])(?!.*%(?![0-9A-Fa-f]{2}))[Hh][Tt][Tt][Pp][Ss]:\\/\\/(?![^/?#]*@)(?:\\[[0-9A-Fa-f:.]+\\](?::\\d+)?|[^/?#\\s\\\\@:%]+(?::\\d+)?)(?:[/?][^\\s\\\\#]*)?$", + "type": "string" + }, "pdpp_token_kinds_supported": { "items": { "enum": [ @@ -1722,6 +1732,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -1749,6 +1762,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -1776,6 +1792,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -2341,287 +2360,1929 @@ } } }, - "/consent/approve": { + "/consent/review": { "post": { - "operationId": "approveConsent", + "operationId": "reviewConsent", "parameters": [], "responses": { "200": { - "description": "Grant approved and client token issued", + "description": "Approval review finalized", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { - "grant": { - "additionalProperties": false, - "properties": { - "access_mode": { - "enum": [ - "single_use", - "continuous" - ], - "type": "string" - }, - "client": { + "approval_review": { + "oneOf": [ + { "additionalProperties": false, "properties": { - "client_display": { + "access_mode": { + "enum": [ + "continuous", + "single_use" + ], + "type": "string" + }, + "ai_training_consented": { + "type": [ + "boolean", + "null" + ] + }, + "client": { "additionalProperties": false, "properties": { - "logo_uri": { - "format": "uri", - "type": "string" + "client_display": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "logo_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "policy_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tos_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] }, - "name": { + "client_id": { "minLength": 1, "type": "string" }, - "policy_uri": { - "format": "uri", + "registration_mode": { + "enum": [ + "dynamic", + "client_id_metadata_document", + "pre_registered_public" + ], "type": "string" + } + }, + "required": [ + "client_id", + "registration_mode" + ], + "type": "object" + }, + "client_claims": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "commitments": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "commitments" + ], + "type": "object" }, - "tos_uri": { + { + "type": "null" + } + ] + }, + "expires_at": { + "type": [ + "string", + "null" + ] + }, + "purpose_code": { + "minLength": 1, + "type": "string" + }, + "purpose_description": { + "type": [ + "string", + "null" + ] + }, + "resolved_streams": { + "items": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "instance_ids": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "name": { + "minLength": 1, + "not": { + "const": "*" + }, + "type": "string" + }, + "resources": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "time_constraint": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "since" + ] + }, + { + "required": [ + "until" + ] + } + ], + "properties": { + "field": { + "minLength": 1, + "type": "string" + }, + "since": { + "format": "date-time", + "type": "string" + }, + "until": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "field" + ], + "type": "object" + } + }, + "required": [ + "name", + "instance_ids", + "fields" + ], + "type": "object" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "retention": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "max_duration": { + "minLength": 1, + "type": "string" + }, + "on_expiry": { + "enum": [ + "delete", + "anonymize" + ], + "type": "string" + } + }, + "required": [ + "max_duration", + "on_expiry" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "selection_preset": { + "type": [ + "string", + "null" + ] + }, + "source": { + "additionalProperties": false, + "properties": { + "id": { "format": "uri", + "minLength": 1, "type": "string" }, - "uri": { - "format": "uri", + "kind": { + "enum": [ + "connector", + "provider_native" + ], "type": "string" } }, + "required": [ + "kind", + "id" + ], "type": "object" }, - "client_id": { - "minLength": 1, - "type": "string" + "source_declaration": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "digest": { + "minLength": 1, + "type": "string" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "digest", + "version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "digest": { + "minLength": 1, + "type": "string" + }, + "publisher_attribution": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "unverified" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "resource_authority": { + "additionalProperties": false, + "properties": { + "status": { + "const": "local_operator_provisioned" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "digest", + "publisher_attribution", + "resource_authority", + "version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "accepted_revision_reference": { + "minLength": 1, + "type": "string" + }, + "digest": { + "minLength": 1, + "type": "string" + }, + "publisher_attribution": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "unverified" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "resource_authority": { + "additionalProperties": false, + "properties": { + "authority_binding": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "verified" + } + }, + "required": [ + "authority_binding", + "status" + ], + "type": "object" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "accepted_revision_reference", + "digest", + "publisher_attribution", + "resource_authority", + "version" + ], + "type": "object" + } + ] + }, + "subject": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "version": { + "const": "reference.approval-review.v1" } }, "required": [ - "client_id" + "access_mode", + "ai_training_consented", + "client", + "client_claims", + "expires_at", + "purpose_code", + "purpose_description", + "resolved_streams", + "retention", + "selection_preset", + "source", + "source_declaration", + "subject", + "version" ], "type": "object" }, - "expires_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] + { + "additionalProperties": false, + "properties": { + "access_mode": { + "enum": [ + "continuous", + "single_use" + ], + "type": [ + "string", + "null" + ] + }, + "approved_source_indexes": { + "items": { + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "client": { + "additionalProperties": false, + "properties": { + "client_display": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "logo_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "policy_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tos_uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "uri": { + "oneOf": [ + { + "format": "uri", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "client_id": { + "minLength": 1, + "type": "string" + }, + "registration_mode": { + "enum": [ + "dynamic", + "client_id_metadata_document", + "pre_registered_public" + ], + "type": "string" + } + }, + "required": [ + "client_id", + "registration_mode" + ], + "type": "object" + }, + "expires_at": { + "type": [ + "string", + "null" + ] + }, + "parent_package_id": { + "type": [ + "string", + "null" + ] + }, + "source_narrowing": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "fields": { + "additionalProperties": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + "since": { + "additionalProperties": { + "minLength": 1, + "type": "string" + }, + "type": "object" + }, + "streams": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "propertyNames": { + "pattern": "^(0|[1-9][0-9]*)$" + }, + "type": "object" + }, + "sources": { + "items": { + "additionalProperties": false, + "properties": { + "access_mode": { + "enum": [ + "continuous", + "single_use" + ], + "type": "string" + }, + "client_claims": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "commitments": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "commitments" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "index": { + "minimum": 0, + "type": "integer" + }, + "purpose_code": { + "minLength": 1, + "type": "string" + }, + "purpose_description": { + "type": [ + "string", + "null" + ] + }, + "resolved_streams": { + "items": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "instance_ids": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "name": { + "minLength": 1, + "not": { + "const": "*" + }, + "type": "string" + }, + "resources": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "time_constraint": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "since" + ] + }, + { + "required": [ + "until" + ] + } + ], + "properties": { + "field": { + "minLength": 1, + "type": "string" + }, + "since": { + "format": "date-time", + "type": "string" + }, + "until": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "field" + ], + "type": "object" + } + }, + "required": [ + "name", + "instance_ids", + "fields" + ], + "type": "object" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "retention": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "max_duration": { + "minLength": 1, + "type": "string" + }, + "on_expiry": { + "enum": [ + "delete", + "anonymize" + ], + "type": "string" + } + }, + "required": [ + "max_duration", + "on_expiry" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "selection_preset": { + "type": [ + "string", + "null" + ] + }, + "source": { + "additionalProperties": false, + "properties": { + "id": { + "format": "uri", + "minLength": 1, + "type": "string" + }, + "kind": { + "enum": [ + "connector", + "provider_native" + ], + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "type": "object" + }, + "source_declaration": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "digest": { + "minLength": 1, + "type": "string" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "digest", + "version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "digest": { + "minLength": 1, + "type": "string" + }, + "publisher_attribution": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "unverified" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "resource_authority": { + "additionalProperties": false, + "properties": { + "status": { + "const": "local_operator_provisioned" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "digest", + "publisher_attribution", + "resource_authority", + "version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "accepted_revision_reference": { + "minLength": 1, + "type": "string" + }, + "digest": { + "minLength": 1, + "type": "string" + }, + "publisher_attribution": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "unverified" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "resource_authority": { + "additionalProperties": false, + "properties": { + "authority_binding": { + "minLength": 1, + "type": "string" + }, + "status": { + "const": "verified" + } + }, + "required": [ + "authority_binding", + "status" + ], + "type": "object" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "accepted_revision_reference", + "digest", + "publisher_attribution", + "resource_authority", + "version" + ], + "type": "object" + } + ] + } + }, + "required": [ + "access_mode", + "client_claims", + "index", + "purpose_code", + "purpose_description", + "resolved_streams", + "retention", + "selection_preset", + "source", + "source_declaration" + ], + "type": "object" + }, + "type": "array" + }, + "subject": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "version": { + "const": "reference.batch-approval-review.v1" + } + }, + "required": [ + "access_mode", + "approved_source_indexes", + "client", + "expires_at", + "parent_package_id", + "source_narrowing", + "sources", + "subject", + "version" + ], + "type": "object" + } + ] + }, + "approval_review_revision": { + "minLength": 1, + "type": "string" + }, + "batch": { + "type": "boolean" + }, + "request_uri": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "approval_review", + "approval_review_revision", + "batch", + "request_uri" + ], + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" }, - "grant_id": { - "minLength": 1, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "Grant is malformed or no longer valid", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "Pending consent request not found", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { "type": "string" }, - "issued_at": { - "format": "date-time", + "resource_metadata": { "type": "string" }, - "purpose_code": { - "format": "uri", - "minLength": 1, + "retry_with": { "type": "string" }, - "purpose_description": { - "minLength": 1, + "type": { "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } + } + }, + "summary": "Finalize a consent review before approval.", + "tags": [ + "grants" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "additionalProperties": false, + "oneOf": [ + { + "required": [ + "request_uri" + ] + }, + { + "required": [ + "approval_id" + ] + } + ], + "properties": { + "approval_id": { + "minLength": 1, + "type": "string" + }, + "ai_training_consented": { + "oneOf": [ + { + "type": "boolean" + }, + { + "enum": [ + "true", + "false", + "1", + "0", + "on", + "off" + ], + "type": "string" + } + ] + }, + "request_uri": { + "minLength": 1, + "type": "string" + }, + "subject_id": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "approved_source_indexes" + ] + }, + { + "required": [ + "confirm_approve_all" + ] + }, + { + "required": [ + "source_narrowing" + ] + } + ], + "oneOf": [ + { + "required": [ + "request_uri" + ] + }, + { + "required": [ + "approval_id" + ] + } + ], + "properties": { + "approval_id": { + "minLength": 1, + "type": "string" + }, + "approved_source_indexes": { + "oneOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "pattern": "^[0-9]+$", + "type": "string" + }, + { + "items": { + "oneOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "pattern": "^[0-9]+$", + "type": "string" + } + ] + }, + "type": "array" + } + ] + }, + "confirm_approve_all": { + "oneOf": [ + { + "type": "boolean" + }, + { + "enum": [ + "true", + "1", + "on" + ], + "type": "string" + } + ] + }, + "request_uri": { + "minLength": 1, + "type": "string" + }, + "source_narrowing": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "fields": { + "additionalProperties": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + "since": { + "additionalProperties": { + "minLength": 1, + "type": "string" + }, + "type": "object" + }, + "streams": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + } + }, + "type": "object" }, - "retention": { + "propertyNames": { + "pattern": "^(0|[1-9][0-9]*)$" + }, + "type": "object" + }, + "subject_id": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "required": true + } + } + }, + "/consent/approve": { + "post": { + "operationId": "approveConsent", + "parameters": [], + "responses": { + "200": { + "description": "Grant approved and client token issued", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "grant": { "additionalProperties": false, "properties": { - "max_duration": { + "access_mode": { + "enum": [ + "single_use", + "continuous" + ], + "type": "string" + }, + "client": { + "additionalProperties": false, + "properties": { + "client_display": { + "additionalProperties": false, + "properties": { + "logo_uri": { + "format": "uri", + "type": "string" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "policy_uri": { + "format": "uri", + "type": "string" + }, + "tos_uri": { + "format": "uri", + "type": "string" + }, + "uri": { + "format": "uri", + "type": "string" + } + }, + "type": "object" + }, + "client_id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "client_id" + ], + "type": "object" + }, + "expires_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "grant_id": { + "minLength": 1, + "type": "string" + }, + "issued_at": { + "format": "date-time", + "type": "string" + }, + "purpose_code": { + "format": "uri", + "minLength": 1, + "type": "string" + }, + "purpose_description": { + "minLength": 1, + "type": "string" + }, + "retention": { + "additionalProperties": false, + "properties": { + "max_duration": { + "minLength": 1, + "type": "string" + }, + "on_expiry": { + "enum": [ + "delete", + "anonymize" + ], + "type": "string" + } + }, + "required": [ + "max_duration", + "on_expiry" + ], + "type": "object" + }, + "selection_preset": { "minLength": 1, "type": "string" }, - "on_expiry": { - "enum": [ - "delete", - "anonymize" + "source": { + "additionalProperties": false, + "properties": { + "id": { + "format": "uri", + "minLength": 1, + "type": "string" + }, + "kind": { + "enum": [ + "connector", + "provider_native" + ], + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "type": "object" + }, + "source_declaration": { + "additionalProperties": false, + "properties": { + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "version" + ], + "type": "object" + }, + "streams": { + "items": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "instance_ids": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "name": { + "minLength": 1, + "not": { + "const": "*" + }, + "type": "string" + }, + "resources": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "time_constraint": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "since" + ] + }, + { + "required": [ + "until" + ] + } + ], + "properties": { + "field": { + "minLength": 1, + "type": "string" + }, + "since": { + "format": "date-time", + "type": "string" + }, + "until": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "field" + ], + "type": "object" + } + }, + "required": [ + "name", + "instance_ids", + "fields" + ], + "type": "object" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "subject": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id" ], + "type": "object" + }, + "version": { + "const": "0.1.0", "type": "string" } }, "required": [ - "max_duration", - "on_expiry" + "version", + "grant_id", + "issued_at", + "subject", + "client", + "source", + "source_declaration", + "purpose_code", + "access_mode", + "streams" ], "type": "object" }, - "selection_preset": { + "grant_id": { "minLength": 1, "type": "string" }, - "source": { + "token": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "grant_id", + "token", + "grant" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "grant": { "additionalProperties": false, "properties": { - "id": { - "format": "uri", + "child_grants": { + "items": { + "additionalProperties": false, + "properties": { + "grant_id": { + "minLength": 1, + "type": "string" + }, + "source": { + "additionalProperties": false, + "properties": { + "connection_id": { + "minLength": 1, + "type": "string" + }, + "id": { + "minLength": 1, + "type": "string" + }, + "kind": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "required": [ + "grant_id", + "source" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "grant_id": { "minLength": 1, "type": "string" }, - "kind": { - "enum": [ - "connector", - "provider_native" - ], - "type": "string" - } - }, - "required": [ - "kind", - "id" - ], - "type": "object" - }, - "source_declaration": { - "additionalProperties": false, - "properties": { - "version": { + "package": { + "const": true + }, + "package_id": { "minLength": 1, "type": "string" } }, "required": [ - "version" + "child_grants", + "grant_id", + "package", + "package_id" ], "type": "object" }, - "streams": { + "package_id": { + "minLength": 1, + "type": "string" + }, + "token": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "grant", + "package_id", + "token" + ], + "type": "object" + } + ] + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { "items": { + "$id": "pdpp/common/ErrorAvailableConnection", "additionalProperties": false, "properties": { - "fields": { - "items": { - "minLength": 1, - "type": "string" - }, - "minItems": 1, - "type": "array", - "uniqueItems": true - }, - "instance_ids": { - "items": { - "minLength": 1, - "type": "string" - }, - "minItems": 1, - "type": "array", - "uniqueItems": true - }, - "name": { - "minLength": 1, - "not": { - "const": "*" - }, + "connection_id": { "type": "string" }, - "resources": { - "items": { - "minLength": 1, - "type": "string" - }, - "minItems": 1, - "type": "array", - "uniqueItems": true - }, - "time_constraint": { - "additionalProperties": false, - "anyOf": [ - { - "required": [ - "since" - ] - }, - { - "required": [ - "until" - ] - } - ], - "properties": { - "field": { - "minLength": 1, - "type": "string" - }, - "since": { - "format": "date-time", - "type": "string" - }, - "until": { - "format": "date-time", - "type": "string" - } - }, - "required": [ - "field" + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" ], - "type": "object" + "type": "string" } }, "required": [ - "name", - "instance_ids", - "fields" + "connection_id" ], "type": "object" }, - "minItems": 1, - "type": "array", - "uniqueItems": true + "type": "array" }, - "subject": { - "additionalProperties": false, - "properties": { - "id": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" + "code": { + "type": "string" }, - "version": { - "const": "0.1.0", + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { "type": "string" } }, "required": [ - "version", - "grant_id", - "issued_at", - "subject", - "client", - "source", - "source_declaration", - "purpose_code", - "access_mode", - "streams" + "type", + "code", + "message", + "request_id" ], "type": "object" - }, - "grant_id": { - "minLength": 1, - "type": "string" - }, - "token": { - "minLength": 1, - "type": "string" } }, "required": [ - "grant_id", - "token", - "grant" + "error" ], "type": "object" } } } }, - "400": { - "description": "Invalid request", + "403": { + "description": "Grant is malformed or no longer valid", "content": { "application/json": { "schema": { @@ -2708,8 +4369,8 @@ } } }, - "403": { - "description": "Grant is malformed or no longer valid", + "404": { + "description": "Pending consent request not found", "content": { "application/json": { "schema": { @@ -2796,8 +4457,8 @@ } } }, - "404": { - "description": "Pending consent request not found", + "409": { + "description": "Pending consent approval conflict", "content": { "application/json": { "schema": { @@ -2893,66 +4554,60 @@ "content": { "application/json": { "schema": { - "additionalProperties": false, - "properties": { - "ai_training_consented": { - "type": "boolean" - }, - "approved_source_indexes": { - "oneOf": [ - { - "minimum": 0, - "type": "integer" - }, - { - "pattern": "^[0-9]+$", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "approval_review_revision": { + "minLength": 1, "type": "string" }, - { - "items": { - "oneOf": [ - { - "minimum": 0, - "type": "integer" - }, - { - "pattern": "^[0-9]+$", - "type": "string" - } - ] - }, - "type": "array" + "request_uri": { + "minLength": 1, + "type": "string" } - ] + }, + "required": [ + "approval_review_revision", + "request_uri" + ], + "type": "object" }, - "confirm_approve_all": { - "oneOf": [ - { - "type": "boolean" + { + "additionalProperties": false, + "properties": { + "approval_review_revision": { + "minLength": 1, + "type": "string" }, - { - "enum": [ - "true", - "1", - "on" - ], + "confirm_reviewed_decision": { + "oneOf": [ + { + "type": "boolean" + }, + { + "enum": [ + "true", + "1", + "on" + ], + "type": "string" + } + ] + }, + "request_uri": { + "minLength": 1, "type": "string" } - ] - }, - "request_uri": { - "minLength": 1, - "type": "string" - }, - "subject_id": { - "minLength": 1, - "type": "string" + }, + "required": [ + "approval_review_revision", + "confirm_reviewed_decision", + "request_uri" + ], + "type": "object" } - }, - "required": [ - "request_uri" - ], - "type": "object" + ] } } }, @@ -3594,6 +5249,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -3712,6 +5370,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -3739,6 +5400,9 @@ "error_description": { "type": "string" }, + "fresh_authorization_required": { + "type": "boolean" + }, "request_id": { "type": "string" } @@ -4238,9 +5902,97 @@ } } } + }, + "401": { + "description": "Confidential resource-server authentication failed", + "content": { + "application/json": { + "schema": { + "$id": "pdpp/common/PdppError", + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "available_connections": { + "items": { + "$id": "pdpp/common/ErrorAvailableConnection", + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "connector_id": { + "type": "string" + }, + "connector_key": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "label_status": { + "enum": [ + "owner_set", + "fallback" + ], + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "next_step": { + "type": "string" + }, + "param": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_metadata": { + "type": "string" + }, + "retry_with": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message", + "request_id" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + } } }, - "summary": "Inspect token activity and, for active client tokens, the bound grant projection.", + "summary": "Inspect token activity for an authenticated confidential resource server.", "tags": [ "oauth" ], @@ -5282,8 +7034,6 @@ "required": [ "schema", "granted", - "exact_filter", - "range_filter", "lexical_search", "semantic_search", "aggregation" @@ -6312,7 +8062,7 @@ } } }, - "summary": "List streams available under the current grant or owner scope. Returns stream-level totals only; for per-field filter capabilities (exact, range operators, aggregation) call `GET /v1/schema` first and consult `field_capabilities` per stream before issuing `filter[...]` queries on `/v1/streams/{stream}/records`. Multi-connection deployments emit one entry per (stream, connection_id); each entry carries `connection_id` and a `display_name` so callers can attribute and disambiguate.", + "summary": "List streams available under the current grant or owner scope. Returns stream-level totals only. Owner-token current-capability callers can consult `GET /v1/schema` for per-field filter capabilities; client-token v0.1 reads reject `filter[...]`. Multi-connection deployments emit one entry per (stream, connection_id); each entry carries `connection_id` and a `display_name` so callers can attribute and disambiguate.", "tags": [ "records" ] @@ -6700,8 +8450,6 @@ "required": [ "schema", "granted", - "exact_filter", - "range_filter", "lexical_search", "semantic_search", "aggregation" @@ -7208,7 +8956,7 @@ } } }, - "summary": "Return stream metadata including declared query capabilities and advisory freshness. For per-field filter capabilities on this stream (exact, range operators, aggregation), prefer `GET /v1/schema` first and read `field_capabilities` rather than guessing `filter[...]` shapes against the records endpoint. Pass `connection_id` (or the deprecated `connector_instance_id` alias) to restrict to a single connection; omitted, the response aggregates across the connections the grant authorizes.", + "summary": "Return stream metadata including declared query capabilities and advisory freshness. Owner-token current-capability callers can consult `GET /v1/schema` for per-field filter capabilities; client-token v0.1 metadata does not advertise typed filter capabilities and client reads reject `filter[...]`. Pass `connection_id` (or the deprecated `connector_instance_id` alias) to restrict to a single connection; omitted, the response aggregates across the connections the grant authorizes.", "tags": [ "records" ] @@ -7308,7 +9056,7 @@ "name": "filter", "required": false, "schema": { - "description": "Per-field filter map. Exact: `filter[field]=value`. Range: `filter[field][op]=value` where `op` is one of the declared `field_capabilities.range_filter.operators` from `GET /v1/schema`.", + "description": "Owner-token current-capability filter map only. Client-token v0.1 reads reject exact `filter[field]=value` and range `filter[field][op]=value` before consulting current source metadata.", "type": "object" } }, @@ -8018,7 +9766,7 @@ } } }, - "summary": "List records in a stream under grant enforcement. Supports logical-cursor pagination, exact and declared range filters, declared one-hop expansion, and changes_since. Per-field filter operators, sortable fields, expandable relations, projection, search modes, and count support are advertised by `GET /v1/schema` (`field_capabilities`, `expand_capabilities`); consult it before issuing `filter[...]`, `expand[]`, or `fields=` shapes to avoid 400 errors. Pass `connection_id` to restrict to one connection; the deprecated `connector_instance_id` alias is accepted for compatibility but new clients SHOULD use `connection_id`.", + "summary": "List records in a stream under grant enforcement. Supports logical-cursor pagination, declared one-hop expansion, and changes_since. Client-token v0.1 reads reject exact and range `filter[...]` parameters before consulting current source metadata; owner-token current-capability reads MAY use declared filters. Per-field query capabilities are advertised by `GET /v1/schema`; consult it before issuing supported query shapes. Pass `connection_id` to restrict to one connection; the deprecated `connector_instance_id` alias is accepted for compatibility but new clients SHOULD use `connection_id`.", "tags": [ "records" ] @@ -8638,7 +10386,7 @@ } } }, - "summary": "Compute a single-stream grant-safe aggregation. Supports count, numeric sum, numeric/date min/max, exact count_distinct, scalar grouped counts (`group_by`), calendar time-bucket counts (`group_by_time`+`granularity`, optional `time_zone` defaulting to UTC), and existing exact/range filters over declared fields. Exactly one grouping dimension per call: `group_by` XOR `group_by_time`. Grouped responses include `other_count` (sum of counts for groups/buckets beyond `limit`) so callers can detect truncation without a second round trip.", + "summary": "Compute a single-stream grant-safe aggregation. Supports count, numeric sum, numeric/date min/max, exact count_distinct, scalar grouped counts (`group_by`), calendar time-bucket counts (`group_by_time`+`granularity`, optional `time_zone` defaulting to UTC), and owner-token current-capability exact/range filters over declared fields. Client-token v0.1 reads reject `filter[...]`. Exactly one grouping dimension per call: `group_by` XOR `group_by_time`. Grouped responses include `other_count` (sum of counts for groups/buckets beyond `limit`) so callers can detect truncation without a second round trip.", "tags": [ "records" ] @@ -9847,7 +11595,7 @@ } } }, - "summary": "Optional lexical retrieval extension: search records across authorized streams by text. Search modes, per-mode cursor support, and field-level `lexical_search`/`semantic_search` capabilities are advertised by `GET /v1/schema`; `filter[...]` operators applied to a single named stream must come from that stream's `field_capabilities`. Hits carry `connection_id` for attribution; the deprecated `connector_instance_id` alias is emitted alongside for compatibility but new clients SHOULD read `connection_id`.", + "summary": "Optional lexical retrieval extension: search records across authorized streams by text. Search modes, per-mode cursor support, and field-level `lexical_search`/`semantic_search` capabilities are advertised by `GET /v1/schema`. Client-token v0.1 reads reject `filter[...]`; owner-token current-capability reads MAY use declared filters. Hits carry `connection_id` for attribution; the deprecated `connector_instance_id` alias is emitted alongside for compatibility but new clients SHOULD read `connection_id`.", "tags": [ "records", "lexical-retrieval" diff --git a/reference-implementation/operations/as-consent-decision/index.ts b/reference-implementation/operations/as-consent-decision/index.ts index c28c63ff7..4fb23eb80 100644 --- a/reference-implementation/operations/as-consent-decision/index.ts +++ b/reference-implementation/operations/as-consent-decision/index.ts @@ -28,13 +28,13 @@ export interface AsConsentDecisionInput { readonly action: AsConsentDecisionAction; readonly approvalId: string | null | undefined; readonly approveOptions?: { - readonly ai_training_consented?: unknown; + readonly approval_review_revision?: unknown; readonly approvedSourceIndexes?: readonly number[]; readonly confirmedApproveAll?: boolean; readonly sourceNarrowing?: Readonly<Record<number, unknown>>; }; readonly requestUri: string | null | undefined; - readonly subjectId: string; + readonly subjectId?: string; } export interface AsConsentDecisionPendingRow { @@ -64,10 +64,10 @@ export interface AsConsentDecisionApproveResult { export interface AsConsentDecisionDependencies { approveGrant: ( deviceCode: string, - subjectId: string, + subjectId: string | undefined, opts: | { - ai_training_consented?: unknown; + approval_review_revision?: unknown; approvedSourceIndexes?: readonly number[]; baseUrl?: string | null; confirmedApproveAll?: boolean; @@ -104,6 +104,8 @@ export interface AsConsentDecisionApproveSuccessOutcome { export interface AsConsentDecisionDenySuccessOutcome { readonly action: "deny"; readonly outcome: "success"; + /** Canonical request URI, including when the caller supplied approval_id. */ + readonly requestUri: string; readonly traceContext: { request_id?: string | null; trace_id?: string | null } | null; } @@ -126,7 +128,7 @@ export async function executeAsConsentDecision( let requestUri = input.requestUri || null; if (!requestUri && input.approvalId) { const row = await deps.getPendingConsentByApprovalId(input.approvalId); - if (row?.status !== "pending") { + if (!(row && (row.status === "pending" || (input.action === "approve" && row.status === "approved")))) { return { errorCode: "not_found", errorMessage: "No pending consent for approval_id", @@ -159,7 +161,7 @@ export async function executeAsConsentDecision( const traceContext = pending?.request?.trace_context ?? null; if (input.action === "approve") { - const approve = await deps.approveGrant(deviceCode, input.subjectId, input.approveOptions); + const approve = await deps.approveGrant(deviceCode, undefined, input.approveOptions); return { action: "approve", grant: approve.grant, @@ -182,6 +184,7 @@ export async function executeAsConsentDecision( return { action: "deny", outcome: "success", + requestUri, traceContext, }; } diff --git a/reference-implementation/operations/as-consent-exchange/index.ts b/reference-implementation/operations/as-consent-exchange/index.ts index 7005d12f7..0cde6c0ab 100644 --- a/reference-implementation/operations/as-consent-exchange/index.ts +++ b/reference-implementation/operations/as-consent-exchange/index.ts @@ -18,12 +18,14 @@ export interface AsConsentExchangeInput { readonly code: string | null | undefined; + readonly proof?: string | null | undefined; } export type AsConsentExchangeConsumeResult = | { readonly ok: true; - readonly grantId: string; + readonly grantId?: string; + readonly packageId?: string; readonly token: string; readonly grant: Record<string, unknown>; } @@ -31,13 +33,15 @@ export type AsConsentExchangeConsumeResult = export interface AsConsentExchangeDependencies { consumeConsentExchangeCode: ( - code: string + code: string, + proof?: string | null | undefined ) => Promise<AsConsentExchangeConsumeResult> | AsConsentExchangeConsumeResult; } export interface AsConsentExchangeSuccessOutcome { readonly envelope: { - readonly grant_id: string; + readonly grant_id?: string; + readonly package_id?: string; readonly token: string; readonly grant: Record<string, unknown>; }; @@ -65,7 +69,7 @@ export async function executeAsConsentExchange( status: 400, }; } - const result = await deps.consumeConsentExchangeCode(input.code); + const result = await deps.consumeConsentExchangeCode(input.code, input.proof); if (!result.ok) { if (result.reason === "expired") { return { @@ -90,11 +94,17 @@ export async function executeAsConsentExchange( status: 404, }; } + let resultIdentity: { package_id: string } | { grant_id: string } | Record<string, never> = {}; + if (result.packageId) { + resultIdentity = { package_id: result.packageId }; + } else if (result.grantId) { + resultIdentity = { grant_id: result.grantId }; + } return { envelope: { grant: result.grant, - grant_id: result.grantId, token: result.token, + ...resultIdentity, }, outcome: "success", }; diff --git a/reference-implementation/operations/as-device-decision/index.ts b/reference-implementation/operations/as-device-decision/index.ts index 65103c462..10a34bb9d 100644 --- a/reference-implementation/operations/as-device-decision/index.ts +++ b/reference-implementation/operations/as-device-decision/index.ts @@ -105,7 +105,7 @@ export async function executeAsDeviceDecision( outcome: "failure", // biome-ignore lint/suspicious/noUnnecessaryConditions: Preserves established ordered async behavior, boundary contract, or dynamic test-harness type where a mechanical rewrite would change semantics. requestId: (err as { request_id?: string | null })?.request_id ?? null, - status: 400, + status: errCode === "approval_conflict" ? 409 : 400, // biome-ignore lint/suspicious/noUnnecessaryConditions: Preserves established ordered async behavior, boundary contract, or dynamic test-harness type where a mechanical rewrite would change semantics. traceId: (err as { trace_id?: string | null })?.trace_id ?? null, }; diff --git a/reference-implementation/operations/as-introspect/index.ts b/reference-implementation/operations/as-introspect/index.ts index 74283b242..5e0bd5095 100644 --- a/reference-implementation/operations/as-introspect/index.ts +++ b/reference-implementation/operations/as-introspect/index.ts @@ -6,8 +6,8 @@ * * Owns the RFC 7662-style introspection envelope semantics for `POST * /introspect`: token-presence validation, the call into the introspect - * capability, and the redaction of the AS-internal - * `grant_storage_binding` field from the public response. + * capability, and the optional projection of the AS-internal + * `grant_storage_binding` field for a confidential resource server. * * Boundary rules (see openspec/changes/complete-reference-operation-refactor): * - This module SHALL NOT import Fastify, Express, Next, SQLite, Postgres, @@ -24,6 +24,7 @@ export type AsIntrospectInfo = Record<string, unknown> & { }; export interface AsIntrospectDependencies { + includeStorageBinding?: boolean; introspect: (token: string) => Promise<AsIntrospectInfo> | AsIntrospectInfo; } @@ -54,10 +55,10 @@ export async function executeAsIntrospect( }; } const info = await deps.introspect(input.token); - // The AS-internal `grant_storage_binding` field is never returned to - // introspection callers. Redaction lives in the operation so any future - // host that mounts this surface inherits the rule automatically. - const { grant_storage_binding: _redacted, ...publicInfo } = info as Record<string, unknown>; + // Redact the AS-internal binding unless the authenticated host explicitly + // identifies the caller as a confidential resource server. + const { grant_storage_binding: _redacted, ...redactedInfo } = info as Record<string, unknown>; + const publicInfo = deps.includeStorageBinding ? info : redactedInfo; return { outcome: "success", publicInfo: publicInfo as AsIntrospectInfo, diff --git a/reference-implementation/operations/ref-approval-detail/index.ts b/reference-implementation/operations/ref-approval-detail/index.ts new file mode 100644 index 000000000..e8f3e0682 --- /dev/null +++ b/reference-implementation/operations/ref-approval-detail/index.ts @@ -0,0 +1,322 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Canonical `ref.approvals.detail` operation. + * + * This is a reference/operator projection, not a PDPP protocol endpoint. The + * host supplies a live, allowlisted review object for an opaque approval id; + * this operation provides a second defensive boundary against raw pending-row + * material reaching an owner-console response. + */ + +export type RefApprovalJson = boolean | null | number | string | RefApprovalJson[] | { [key: string]: RefApprovalJson }; + +export interface RefApprovalStreamReview { + readonly client_claims: RefApprovalJson | null; + readonly connection_id: string | null; + readonly fields: readonly string[] | null; + readonly name: string; + readonly necessity: string | null; + readonly resources: readonly RefApprovalJson[] | null; + readonly time_range: { readonly since: string | null } | null; + readonly view: string | null; +} + +export interface RefApprovalConsentDetail { + readonly approval_id: string; + readonly client: { + readonly client_id: string; + readonly display: { + readonly name: string | null; + readonly policy_uri: string | null; + readonly tos_uri: string | null; + readonly uri: string | null; + }; + readonly registration_mode: string; + }; + readonly created_at: string; + readonly expires_at: string; + readonly grant_outcome: { readonly access_mode: string; readonly description: string }; + readonly kind: "consent"; + readonly object: "approval_review"; + readonly purpose: { readonly code: string | null; readonly description: string | null }; + readonly retention: RefApprovalJson | null; + readonly source: { readonly id: string; readonly kind: "connector" | "provider_native" } | null; + readonly streams: readonly RefApprovalStreamReview[]; + readonly trust: "unverified"; +} + +export interface RefApprovalOwnerDeviceDetail { + readonly approval_id: string; + readonly client_id: string; + readonly created_at: string; + readonly expires_at: string; + readonly kind: "owner_device"; + readonly object: "approval_review"; +} + +export type RefApprovalDetail = RefApprovalConsentDetail | RefApprovalOwnerDeviceDetail; + +export interface RefApprovalDetailDependencies { + getPendingApprovalDetail: () => Promise<RefApprovalDetail | null> | RefApprovalDetail | null; +} + +const FORBIDDEN_PROPERTY_NAMES = new Set([ + "access_token", + "api_key", + "authorization", + "auth_token", + "bearer_token", + "client_secret", + "device_code", + "id_token", + "params_json", + "password", + "refresh_token", + "request_uri", + "secret", + "token", + "user_code", +]); + +function normalizedPropertyName(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/[-\s]/g, "_") + .toLowerCase(); +} + +function isForbiddenPropertyName(value: string): boolean { + return FORBIDDEN_PROPERTY_NAMES.has(normalizedPropertyName(value)); +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stringField(value: unknown): string | null { + return typeof value === "string" && value ? value : null; +} + +function safeDisplayUri(value: unknown): string | null { + if (typeof value !== "string" || value.trim() !== value || !value) { + return null; + } + try { + const url = new URL(value); + if ((url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password) { + return url.href; + } + } catch { + return null; + } + return null; +} + +function safeJson(value: unknown): RefApprovalJson | null { + if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") { + return value; + } + if (Array.isArray(value)) { + return value.map(safeJson).filter((item): item is RefApprovalJson => item !== null); + } + if (!isRecord(value)) { + return null; + } + const out: Record<string, RefApprovalJson> = {}; + for (const [key, nested] of Object.entries(value)) { + if (isForbiddenPropertyName(key)) { + continue; + } + const safe = safeJson(nested); + if (safe !== null) { + out[key] = safe; + } + } + return out; +} + +function streamReview(value: unknown): RefApprovalStreamReview | null { + if (!isRecord(value)) { + return null; + } + const name = stringField(value.name); + if (!name) { + return null; + } + const fields = Array.isArray(value.fields) + ? value.fields.filter((field): field is string => typeof field === "string") + : null; + const resources = Array.isArray(value.resources) + ? value.resources.map(safeJson).filter((item): item is RefApprovalJson => item !== null) + : null; + const timeRange = isRecord(value.time_range) ? { since: stringField(value.time_range.since) } : null; + return { + client_claims: safeJson(value.client_claims), + connection_id: stringField(value.connection_id), + fields, + name, + necessity: stringField(value.necessity), + resources, + time_range: timeRange, + view: stringField(value.view), + }; +} + +function grantOutcome(accessMode: string): RefApprovalConsentDetail["grant_outcome"] { + if (accessMode === "single_use") { + return { + access_mode: accessMode, + description: "One-time access; this reference grant expires 24 hours after approval.", + }; + } + return { + access_mode: accessMode, + description: "Ongoing access; this reference implementation sets no grant expiry.", + }; +} + +export function isPendingApprovalRow(row: { expires_at?: unknown; status?: unknown } | null): boolean { + return Boolean( + row && + row.status === "pending" && + typeof row.expires_at === "string" && + Number.isFinite(Date.parse(row.expires_at)) && + Date.parse(row.expires_at) > Date.now() + ); +} + +export function buildConsentApprovalDetail( + row: { + approval_id?: unknown; + created_at?: unknown; + expires_at?: unknown; + }, + request: Record<string, unknown>, + resolvedStreams: unknown[] +): RefApprovalConsentDetail | null { + if (typeof row.approval_id !== "string" || typeof row.created_at !== "string" || typeof row.expires_at !== "string") { + return null; + } + if (!(isRecord(request.client) && isRecord(request.selection))) { + return null; + } + const clientId = stringField(request.client.client_id); + const accessMode = stringField(request.selection.access_mode); + if (!(clientId && accessMode)) { + return null; + } + const display = isRecord(request.client.client_display) ? request.client.client_display : {}; + const source: RefApprovalConsentDetail["source"] = + isRecord(request.source_binding) && + (request.source_binding.kind === "connector" || request.source_binding.kind === "provider_native") && + typeof request.source_binding.id === "string" + ? { id: request.source_binding.id, kind: request.source_binding.kind } + : null; + const streams = resolvedStreams + .map(streamReview) + .filter((stream): stream is RefApprovalStreamReview => stream !== null); + return { + approval_id: row.approval_id, + client: { + client_id: clientId, + display: { + name: stringField(display.name), + policy_uri: safeDisplayUri(display.policy_uri), + tos_uri: safeDisplayUri(display.tos_uri), + uri: safeDisplayUri(display.uri), + }, + registration_mode: stringField(request.client.registration_mode) ?? "unknown", + }, + created_at: row.created_at, + expires_at: row.expires_at, + grant_outcome: grantOutcome(accessMode), + kind: "consent", + object: "approval_review", + purpose: { + code: stringField(request.selection.purpose_code), + description: stringField(request.selection.purpose_description), + }, + retention: safeJson(request.selection.retention), + source, + streams, + trust: "unverified", + }; +} + +export function buildLiveConsentApprovalDetail( + row: { + approval_id?: unknown; + created_at?: unknown; + expires_at?: unknown; + status?: unknown; + } | null, + pending: { request?: unknown; resolvedStreams?: unknown } | null +): RefApprovalConsentDetail | null { + if (!(row && isPendingApprovalRow(row))) { + return null; + } + if (!isRecord(pending?.request)) { + return null; + } + const resolvedStreams = Array.isArray(pending.resolvedStreams) ? pending.resolvedStreams : []; + return buildConsentApprovalDetail(row, pending.request, resolvedStreams); +} + +export function buildOwnerDeviceApprovalDetail( + row: { + approval_id?: unknown; + client_id?: unknown; + created_at?: unknown; + expires_at?: unknown; + status?: unknown; + } | null +): RefApprovalOwnerDeviceDetail | null { + if ( + !(row && isPendingApprovalRow(row)) || + typeof row.approval_id !== "string" || + typeof row.client_id !== "string" || + typeof row.created_at !== "string" || + typeof row.expires_at !== "string" + ) { + return null; + } + return { + approval_id: row.approval_id, + client_id: row.client_id, + created_at: row.created_at, + expires_at: row.expires_at, + kind: "owner_device", + object: "approval_review", + }; +} + +function assertNoForbiddenProperty(value: RefApprovalJson | RefApprovalDetail, path = "approval review"): void { + if (Array.isArray(value)) { + for (const item of value) { + assertNoForbiddenProperty(item, path); + } + return; + } + if (!value || typeof value !== "object") { + return; + } + for (const [key, nested] of Object.entries(value)) { + if (isForbiddenPropertyName(key)) { + throw new Error(`ref.approvals.detail: dependency leaked forbidden ${key} at ${path}`); + } + assertNoForbiddenProperty(nested as RefApprovalJson, `${path}.${key}`); + } +} + +export async function executeRefApprovalDetail( + dependencies: RefApprovalDetailDependencies +): Promise<RefApprovalDetail | null> { + const detail = await dependencies.getPendingApprovalDetail(); + if (!detail) { + return null; + } + assertNoForbiddenProperty(detail); + return detail; +} diff --git a/reference-implementation/operations/ref-approvals-list/index.ts b/reference-implementation/operations/ref-approvals-list/index.ts index 879c6bba4..d7d05035c 100644 --- a/reference-implementation/operations/ref-approvals-list/index.ts +++ b/reference-implementation/operations/ref-approvals-list/index.ts @@ -42,6 +42,8 @@ export interface RefApprovalConsentGrantPreview { export interface RefApprovalConsent { readonly approval_id: string; + /** True when the hosted per-source batch ceremony is required. */ + readonly batch: boolean; readonly client_id: string | null; readonly created_at: string; readonly grant_preview: RefApprovalConsentGrantPreview; diff --git a/reference-implementation/operations/rs-client-event-derive/index.ts b/reference-implementation/operations/rs-client-event-derive/index.ts index fba0f679f..8f652625b 100644 --- a/reference-implementation/operations/rs-client-event-derive/index.ts +++ b/reference-implementation/operations/rs-client-event-derive/index.ts @@ -21,26 +21,27 @@ export interface RecordChangeDescriptor { readonly connectionId?: string | null; readonly connectorId: string; readonly connectorInstanceId: string; + readonly data?: Readonly<Record<string, unknown>> | null; readonly emittedAt: string; /** Owner subject for the changed connector instance, when known. */ readonly ownerSubjectId?: string | null; + readonly recordKey?: string | null; readonly stream: string; /** Monotonic per (connector_instance, stream) version from `record_changes`. */ readonly version: number; } export interface SubscriptionScopeStream { - /** Optional connection narrowing inherited from the grant. */ - readonly connection_id?: string | null; + readonly instance_ids?: readonly string[]; readonly name: string; readonly resources?: readonly string[]; - readonly time_range?: { start?: string | null; end?: string | null }; + readonly time_constraint?: { field: string; since?: string | null; until?: string | null }; } export interface SubscriptionScope { /** Optional client-supplied narrowing (subset of stream names from grant). */ readonly filters?: { streams?: readonly string[] }; - readonly source?: { kind?: string; id?: string }; + readonly source?: { connector_id?: string; kind?: string; id?: string }; readonly streams: readonly SubscriptionScopeStream[]; } @@ -78,19 +79,45 @@ function findScopeStream(scope: SubscriptionScope, stream: string): Subscription return scope.streams.find((s) => s.name === stream) ?? scope.streams.find((s) => s.name === "*") ?? null; } -function inGrantScope(scope: SubscriptionScope, stream: string, connectionId: string | null | undefined): boolean { +function changePassesTimeConstraint( + change: RecordChangeDescriptor, + constraint: SubscriptionScopeStream["time_constraint"] +): boolean { + if (!constraint) { + return true; + } + const rawValue = change.data?.[constraint.field]; + const recordTime = typeof rawValue === "string" ? Date.parse(rawValue) : Number.NaN; + if (Number.isNaN(recordTime)) { + return false; + } + const since = constraint.since ? Date.parse(constraint.since) : null; + const until = constraint.until ? Date.parse(constraint.until) : null; + return !( + (since !== null && (Number.isNaN(since) || recordTime < since)) || + (until !== null && (Number.isNaN(until) || recordTime >= until)) + ); +} + +function inGrantScope(scope: SubscriptionScope, change: RecordChangeDescriptor): boolean { const filterList = scope.filters?.streams; - if (filterList && !filterList.includes(stream)) { + if (filterList && !filterList.includes(change.stream)) { return false; } - const match = findScopeStream(scope, stream); + if (!(scope.source?.connector_id && scope.source.connector_id === change.connectorId)) { + return false; + } + const match = findScopeStream(scope, change.stream); if (!match) { return false; } - if (match.connection_id && connectionId && match.connection_id !== connectionId) { + if (!(Array.isArray(match.instance_ids) && match.instance_ids.includes(change.connectorInstanceId))) { return false; } - return true; + if (match.resources?.length && !(change.recordKey && match.resources.includes(change.recordKey))) { + return false; + } + return changePassesTimeConstraint(change, match.time_constraint); } function subscriptionCanSeeChange(sub: ActiveSubscription, change: RecordChangeDescriptor): boolean { @@ -100,7 +127,11 @@ function subscriptionCanSeeChange(sub: ActiveSubscription, change: RecordChangeD ) { return false; } - return inGrantScope(sub.scope, change.stream, change.connectionId ?? null); + if (sub.authorityKind === "trusted_owner_agent") { + const filterList = sub.scope.filters?.streams; + return !(filterList && !filterList.includes(change.stream)) && Boolean(findScopeStream(sub.scope, change.stream)); + } + return inGrantScope(sub.scope, change); } function encodeChangesSinceCursor(version: number): string { @@ -134,7 +165,9 @@ export function deriveClientEventsFromRecordChange( } const scopeStream = findScopeStream(sub.scope, change.stream); const includeConnectionId = - sub.authorityKind === "trusted_owner_agent" || scopeStream?.name === "*" || Boolean(scopeStream?.connection_id); + sub.authorityKind === "trusted_owner_agent" || + scopeStream?.name === "*" || + Boolean(scopeStream?.instance_ids?.length); out.push({ data: { ...(sub.authorityKind === "trusted_owner_agent" ? { connector_id: change.connectorId } : {}), diff --git a/reference-implementation/operations/rs-records-detail/index.ts b/reference-implementation/operations/rs-records-detail/index.ts index 52e53c496..ba1cf247a 100644 --- a/reference-implementation/operations/rs-records-detail/index.ts +++ b/reference-implementation/operations/rs-records-detail/index.ts @@ -23,6 +23,7 @@ * * What the operation owns: * - `not_found` error mapping when the record does not exist; + * - grant stream visibility for client actors (`grant_stream_not_allowed`); * - field projection validation against the manifest stream; * - owner read-grant construction for the actor's stream; * - output shape (decorated record + instrumentation data blocks). @@ -171,17 +172,17 @@ export interface RecordDetailOutput { * route-compatible response without translation. */ export class RecordDetailVisibilityError extends Error { - readonly code: "not_found"; + readonly code: "not_found" | "grant_stream_not_allowed"; - constructor(message: string) { + constructor(message: string, code: "not_found" | "grant_stream_not_allowed" = "not_found") { super(message); this.name = "RecordDetailVisibilityError"; - this.code = "not_found"; + this.code = code; } } -function buildOwnerReadGrant(streamName: string): RecordDetailGrant { - return { streams: [{ name: streamName }] }; +function buildOwnerReadGrant(manifest: RecordDetailManifest): RecordDetailGrant { + return { streams: manifest.streams.map((stream) => ({ name: stream.name })) }; } /** @@ -196,7 +197,9 @@ export async function executeRecordDetail( let grant = dependencies.getGrant(); if (input.actor.kind === "owner") { - grant = buildOwnerReadGrant(input.streamName); + grant = buildOwnerReadGrant(manifest); + } else if (!grant.streams.some((stream) => stream.name === input.streamName)) { + throw new RecordDetailVisibilityError(`Stream '${input.streamName}' not in grant`, "grant_stream_not_allowed"); } const expandOptions: RecordDetailExpandOptions = input.expandOptions ?? {}; diff --git a/reference-implementation/operations/rs-records-list/index.ts b/reference-implementation/operations/rs-records-list/index.ts index 5e9c70163..e3c907cb4 100644 --- a/reference-implementation/operations/rs-records-list/index.ts +++ b/reference-implementation/operations/rs-records-list/index.ts @@ -25,8 +25,9 @@ * What the operation owns: * - view/fields mutual exclusion; * - manifest stream visibility for owner actors (`not_found`); - * - view → fields resolution against the grant - * (`field_not_granted` when a view names ungranted fields); + * - grant stream visibility for client actors (`grant_stream_not_allowed`); + * - owner view → fields resolution against current capability metadata; + * - client view rejection because grants carry the frozen field projection; * - field/filter validation against the manifest stream; * - owner read-grant construction; * - output envelope shape and `query.received` / `disclosure.served` data @@ -206,17 +207,20 @@ export interface RecordsListOutput { /** Error thrown when the request itself is invalid in a host-independent way. */ export class RecordsListVisibilityError extends Error { - readonly code: "not_found" | "invalid_request" | "field_not_granted"; + readonly code: "not_found" | "invalid_request" | "field_not_granted" | "grant_stream_not_allowed"; - constructor(code: "not_found" | "invalid_request" | "field_not_granted", message: string) { + constructor( + code: "not_found" | "invalid_request" | "field_not_granted" | "grant_stream_not_allowed", + message: string + ) { super(message); this.name = "RecordsListVisibilityError"; this.code = code; } } -function buildOwnerReadGrant(streamName: string): RecordsListGrant { - return { streams: [{ name: streamName }] }; +function buildOwnerReadGrant(manifest: RecordsListManifest): RecordsListGrant { + return { streams: manifest.streams.map((stream) => ({ name: stream.name })) }; } /** @@ -236,15 +240,14 @@ export async function executeRecordsList( const manifest = await dependencies.getManifest(); let grant = dependencies.getGrant(); - // Owner manifest-visibility check. Client actors rely on grant scope to - // bound visibility; their manifest stream may not be present and the - // existing native route does not 404 in that branch. if (input.actor.kind === "owner") { const mStream = manifest.streams.find((s) => s.name === input.streamName); if (!mStream) { throw new RecordsListVisibilityError("not_found", `Stream '${input.streamName}' not found`); } - grant = buildOwnerReadGrant(input.streamName); + grant = buildOwnerReadGrant(manifest); + } else if (!grant.streams.some((stream) => stream.name === input.streamName)) { + throw new RecordsListVisibilityError("grant_stream_not_allowed", `Stream '${input.streamName}' not in grant`); } // View / fields mutual exclusion runs as a truthiness test against the @@ -259,12 +262,18 @@ export async function executeRecordsList( if (rawView && rawFields) { throw new RecordsListVisibilityError("invalid_request", "view and fields are mutually exclusive"); } + if (rawView && input.actor.kind !== "owner") { + throw new RecordsListVisibilityError( + "invalid_request", + "Client record reads must use explicit fields; views are resolved when the grant is issued" + ); + } const mStream = manifest.streams.find((s) => s.name === input.streamName) ?? null; dependencies.validateRequestFields(input.requestParams, mStream); - // View → fields resolution. Only runs when the request asks for a view + // Owner view → fields resolution. Only runs when the request asks for a view // and `fields` was not already promoted by the validator (preserves // prior native ordering: validate fields if present, then resolve view // if no fields were supplied). View id comparison uses `===` against @@ -272,22 +281,15 @@ export async function executeRecordsList( // the "Unknown view" rejection, matching the previous native behavior // (which embedded `req.query.view` directly into the template literal, // coercing arrays/objects to their default string form). - if (rawView && (input.requestParams.fields === null || input.requestParams.fields === undefined)) { + if ( + input.actor.kind === "owner" && + rawView && + (input.requestParams.fields === null || input.requestParams.fields === undefined) + ) { const viewDef = (mStream?.views ?? []).find((v) => v.id === rawView); if (!viewDef) { throw new RecordsListVisibilityError("invalid_request", `Unknown view: ${String(rawView)}`); } - const streamGrant = grant.streams.find((s) => s.name === input.streamName); - if (streamGrant?.fields) { - const granted = streamGrant.fields; - const unauthorized = viewDef.fields.filter((f) => !granted.includes(f)); - if (unauthorized.length) { - throw new RecordsListVisibilityError( - "field_not_granted", - `View includes fields not in grant: ${unauthorized.join(", ")}` - ); - } - } input.requestParams.fields = viewDef.fields; Reflect.deleteProperty(input.requestParams, "view"); } diff --git a/reference-implementation/operations/rs-search-lexical/index.ts b/reference-implementation/operations/rs-search-lexical/index.ts index 63c7df5fa..a9f1e826d 100644 --- a/reference-implementation/operations/rs-search-lexical/index.ts +++ b/reference-implementation/operations/rs-search-lexical/index.ts @@ -54,6 +54,8 @@ // ─── Errors ──────────────────────────────────────────────────────────────── +import { searchAuthorityKey } from "../search-authority-key.ts"; + export type SearchLexicalErrorCode = | "invalid_request" | "invalid_argument" @@ -108,6 +110,7 @@ export interface SearchLexicalManifest { export interface SearchLexicalGrantStream { fields?: string[]; + instance_ids?: string[]; name: string; [extra: string]: unknown; } @@ -245,6 +248,7 @@ export interface SearchLexicalSnapshotRecall { } export interface SearchLexicalSnapshot { + authority_key?: string; query: string; /** * Operation-level recall/count disclosure for the *whole* ranked set. The @@ -369,8 +373,7 @@ export interface SearchLexicalDependencies { * connector plan per binding. * * Implementations MUST honor: - * - grant-scope per-stream `connection_id` constraints - * (`grant.streams[].connection_id`); + * - grant-scope per-stream `instance_ids` authority; * - request-time `connection_id` / deprecated `connector_instance_id` * alias narrowing; * - exactly-one auto-select when only one binding is addressable. @@ -1055,11 +1058,10 @@ export async function executeSearchLexical( } } } - const connectorId = - (grant as { source?: { kind?: unknown; id?: unknown } } | null)?.source?.kind === "connector" && - typeof (grant as { source?: { id?: unknown } } | null)?.source?.id === "string" - ? ((grant as { source?: { id?: string } }).source?.id as string) - : null; + // source.id is authorization identity, not a local connector key. The + // host resolves the persisted storage binding and supplies that identity + // through each binding manifest. + const connectorId: string | null = null; // Cross-binding fan-in path for client mode. When the host wires the // binding-aware resolver, the operation iterates every binding the grant // authorizes (after narrowing) and emits one connector plan per binding. @@ -1115,6 +1117,21 @@ export async function executeSearchLexical( // 4. Resolve cursor → snapshot. Fresh request: build & persist; cursor // request: load by id. + const authorityKey = searchAuthorityKey({ + actor: input.actor, + connection_id: requestConnectionId, + plans: perConnectorPlans.map((plan) => ({ + connector_id: plan.connectorId, + grant: plan.grant, + plan_entries: plan.planEntries, + })), + query: { + filter: params.filter, + filtered_stream: params.filteredStream, + q: params.q, + streams: params.streams, + }, + }); let snapshot: SearchLexicalSnapshot; let snapshotId: string; let offset: number; @@ -1127,6 +1144,9 @@ export async function executeSearchLexical( if (!loaded) { throw new SearchLexicalRequestError("invalid_cursor", "Cursor refers to an expired or unknown snapshot"); } + if (loaded.authority_key !== authorityKey || loaded.query !== params.q) { + throw new SearchLexicalRequestError("invalid_cursor", "Cursor does not match this query and grant authority"); + } snapshot = loaded; snapshotId = decoded.snap; offset = decoded.off; @@ -1136,7 +1156,7 @@ export async function executeSearchLexical( // stream; planner omission must short-circuit before the host adapter can // scan records or FTS state. if (perConnectorPlans.length === 0) { - snapshot = { query: params.q, results: [], snapshot_id: "" }; + snapshot = { authority_key: authorityKey, query: params.q, results: [], snapshot_id: "" }; snapshotId = ""; } else { snapshot = await dependencies.buildSnapshot({ @@ -1144,6 +1164,7 @@ export async function executeSearchLexical( perConnectorPlans, q: params.q, }); + snapshot.authority_key = authorityKey; snapshotId = snapshot.snapshot_id; await dependencies.persistSnapshot(snapshot); } diff --git a/reference-implementation/operations/rs-search-semantic/index.ts b/reference-implementation/operations/rs-search-semantic/index.ts index 5036d8031..464fccaff 100644 --- a/reference-implementation/operations/rs-search-semantic/index.ts +++ b/reference-implementation/operations/rs-search-semantic/index.ts @@ -72,6 +72,8 @@ // ─── Errors ──────────────────────────────────────────────────────────────── +import { searchAuthorityKey } from "../search-authority-key.ts"; + export type SearchSemanticErrorCode = | "invalid_request" | "invalid_argument" @@ -205,6 +207,7 @@ export interface SearchSemanticSnapshotResult { } export interface SearchSemanticSnapshot { + authority_key?: string; /** * Opaque backend identity hash captured at snapshot build time. The * operation compares it against `getCurrentBackendIdentity()` on cursor @@ -245,6 +248,7 @@ export interface SearchSemanticOwnerBinding { * downstream plan compiler scopes vector queries to that binding. */ export interface SearchSemanticClientBinding { + connectorId?: string | null; connectorInstanceId: string; displayName?: string | null; manifest: SearchSemanticManifest; @@ -939,19 +943,27 @@ export async function executeSearchSemantic( } } } - const connectorId = - (grant as { source?: { kind?: unknown; id?: unknown } } | null)?.source?.kind === "connector" && - typeof (grant as { source?: { id?: unknown } } | null)?.source?.id === "string" - ? ((grant as { source?: { id?: string } }).source?.id as string) - : null; + // source.id is authorization identity, not a local connector key. The + // host resolves the persisted storage binding and supplies that identity + // through each binding manifest. + const connectorId: string | null = null; if (typeof dependencies.resolveClientBindings === "function") { const clientBindings = await dependencies.resolveClientBindings( { grant, kind: "client" }, { connectionId: requestConnectionId } ); for (const cb of clientBindings) { + const bindingManifest = cb.manifest as SearchSemanticManifest & { + connector_id?: string | null; + storage_binding?: { connector_id?: string | null } | null; + }; + const bindingConnectorId = + cb.connectorId ?? + bindingManifest.storage_binding?.connector_id ?? + bindingManifest.connector_id ?? + connectorId; const planEntries = dependencies.buildSearchPlanForGrant({ - connectorId, + connectorId: bindingConnectorId, filter: params.filter, filteredStream: params.filteredStream, grant, @@ -961,12 +973,12 @@ export async function executeSearchSemantic( if (planEntries.length === 0) { skippedSources.push({ connection_id: cb.connectorInstanceId, - source: connectorId ?? "", + source: bindingConnectorId ?? "", }); continue; } perConnectorPlans.push({ - connectorId, + connectorId: bindingConnectorId, grant, manifest: cb.manifest, planEntries, @@ -993,6 +1005,21 @@ export async function executeSearchSemantic( // 4. Resolve cursor → snapshot. Fresh request: build & persist; cursor // request: load by id and verify backend identity. + const authorityKey = searchAuthorityKey({ + actor: input.actor, + connection_id: requestConnectionId, + plans: perConnectorPlans.map((plan) => ({ + connector_id: plan.connectorId, + grant: plan.grant, + plan_entries: plan.planEntries, + })), + query: { + filter: params.filter, + filtered_stream: params.filteredStream, + q: params.q, + streams: params.streams, + }, + }); let snapshot: SearchSemanticSnapshot; let snapshotId: string; let offset: number; @@ -1005,6 +1032,9 @@ export async function executeSearchSemantic( if (!loaded) { throw new SearchSemanticRequestError("invalid_cursor", "Cursor refers to an expired or unknown snapshot"); } + if (loaded.authority_key !== authorityKey || loaded.query !== params.q) { + throw new SearchSemanticRequestError("invalid_cursor", "Cursor does not match this query and grant authority"); + } // Stale-cursor backend-identity check: any divergence ⇒ invalid_cursor. // Recomputing under a different model would be dishonest — the spec // permits this and the previous native behavior raises the same code. @@ -1021,6 +1051,7 @@ export async function executeSearchSemantic( // an empty result without touching vector/index storage or snapshots. if (perConnectorPlans.length === 0) { snapshot = { + authority_key: authorityKey, backend_hash: dependencies.getCurrentBackendIdentity(), query: params.q, results: [], @@ -1034,6 +1065,7 @@ export async function executeSearchSemantic( perConnectorPlans, q: params.q, }); + snapshot.authority_key = authorityKey; snapshotId = snapshot.snapshot_id; await dependencies.persistSnapshot(snapshot); } diff --git a/reference-implementation/operations/search-authority-key.ts b/reference-implementation/operations/search-authority-key.ts new file mode 100644 index 000000000..a3b5d0d29 --- /dev/null +++ b/reference-implementation/operations/search-authority-key.ts @@ -0,0 +1,24 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableJson).sort().join(",")}]`; + } + if (value && typeof value === "object") { + const record = value as Record<string, unknown>; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +/** Identify one exact search request and its fully resolved authority plan. */ +export function searchAuthorityKey(value: unknown): string { + return createHash("sha256").update(stableJson(value)).digest("hex"); +} diff --git a/reference-implementation/package.json b/reference-implementation/package.json index 64ed87874..417f347fd 100644 --- a/reference-implementation/package.json +++ b/reference-implementation/package.json @@ -40,6 +40,9 @@ "cli": "node cli/index.ts", "example-client": "node examples/third-party-app/server.ts", "test": "node --import tsx ../scripts/test-scratch/run-command.ts -- node scripts/run-tests.ts", + "check:seam:pr89:receipt": "node --import tsx ../scripts/test-scratch/run-command.ts -- node --import tsx scripts/check-pr89-seam-receipt.ts", + "test:seam:pr89": "node --import tsx ../scripts/test-scratch/run-command.ts -- node --import tsx scripts/run-pr89-seam.ts", + "test:seam:pr89:receipt": "node --import tsx ../scripts/test-scratch/run-command.ts -- node --test --import tsx scripts/check-pr89-seam-receipt.test.ts", "test:semantic-multilingual-smoke": "PDPP_MULTILINGUAL_MINILM_SMOKE=1 node --import tsx ../scripts/test-scratch/run-command.ts -- node --test --test-timeout=240000 --test-name-pattern \"multilingual-minilm profile\" test/semantic-retrieval.test.ts", "test:live-cdp": "PDPP_TEST_LIVE_CDP=1 node --import tsx ../scripts/test-scratch/run-command.ts -- node --test test/run-interaction-stream-cdp-live.test.ts", "test:live-neko": "PDPP_TEST_LIVE_NEKO=1 node --import tsx ../scripts/test-scratch/run-command.ts -- node --test --import tsx test/run-interaction-stream-neko-live.test.ts", diff --git a/reference-implementation/runtime/index.ts b/reference-implementation/runtime/index.ts index 0203b8c6c..8001a26ce 100644 --- a/reference-implementation/runtime/index.ts +++ b/reference-implementation/runtime/index.ts @@ -4500,6 +4500,32 @@ export async function runConnector(opts: RuntimeRunConnectorOptions): Promise<Ru await rejectAfterLeaseAccounting(error); } + function effectiveDoneStatus(doneStatus: DoneMessageState["status"]): DoneMessageState["status"] { + return ownerCancelRequested ? "cancelled" : doneStatus; + } + + function doneTerminalEventType( + status: DoneMessageState["status"] + ): "run.cancelled" | "run.completed" | "run.failed" { + if (status === "succeeded") { + return "run.completed"; + } + if (status === "cancelled" && ownerCancelRequested) { + return "run.cancelled"; + } + return "run.failed"; + } + + function doneTerminalReason(status: DoneMessageState["status"]): string | null { + if (status === "failed") { + return "connector_reported_failed"; + } + if (status === "cancelled") { + return ownerCancelRequested ? "owner_cancelled" : "connector_reported_cancelled"; + } + return null; + } + async function handleDoneClose(code: number | null): Promise<boolean> { const done = doneMessage; if (!done) { @@ -4541,22 +4567,21 @@ export async function runConnector(opts: RuntimeRunConnectorOptions): Promise<Ru cleanupChildHandles(); await awaitLeaseAccounting(); - if (done.status === "succeeded" && persistState) { + const terminalStatus = effectiveDoneStatus(done.status); + finalStatus = terminalStatus; + + if (terminalStatus === "succeeded" && persistState) { assertDetailCoverageSatisfiedBeforeCommit(); await Object.entries(newState).reduce( (previous, [stream, cursor]) => previous.then(() => commitState(stream, cursor)), Promise.resolve() ); } - const assistanceStatus = done.status === "succeeded" ? "resolved" : "cancelled"; - const assistanceReason = done.status === "succeeded" ? "run_completed" : "connector_reported_failed"; + const assistanceStatus = terminalStatus === "succeeded" ? "resolved" : "cancelled"; + const assistanceReason = + terminalStatus === "succeeded" ? "run_completed" : (doneTerminalReason(terminalStatus) ?? "run_cancelled"); await closeOpenStructuredAssistance(assistanceStatus, { reason: assistanceReason }); - let terminalEventReason: string | null = null; - if (done.status === "failed") { - terminalEventReason = "connector_reported_failed"; - } else if (done.status === "cancelled") { - terminalEventReason = "connector_reported_cancelled"; - } + const terminalEventReason = doneTerminalReason(terminalStatus); await emitRunSpineEvent({ actor_id: connectorId, actor_type: "runtime", @@ -4565,15 +4590,15 @@ export async function runConnector(opts: RuntimeRunConnectorOptions): Promise<Ru reason: terminalEventReason, recordsEmitted: done.records_emitted, }), - event_type: done.status === "succeeded" ? "run.completed" : "run.failed", + event_type: doneTerminalEventType(terminalStatus), object_id: runId, object_type: "run", run_id: runId, scenario_id: traceContext.scenario_id, - status: done.status, + status: terminalStatus, trace_id: traceContext.trace_id, }); - onProgress({ records_emitted: done.records_emitted, status: done.status, type: "done" }); + onProgress({ records_emitted: done.records_emitted, status: terminalStatus, type: "done" }); return false; } @@ -4673,7 +4698,7 @@ export async function runConnector(opts: RuntimeRunConnectorOptions): Promise<Ru let closeTerminalReason = derivedTerminal.reason; if (runTimedOut) { closeTerminalReason = runtimeTimeoutReason || "run_timed_out"; - } else if (finalStatus === "cancelled" && ownerCancelRequested && !doneMessage) { + } else if (finalStatus === "cancelled" && ownerCancelRequested) { closeTerminalReason = ownerCancelForced ? "owner_cancel_forced" : "owner_cancelled"; } const closeTerminalPhase = derivedTerminal.phase; diff --git a/reference-implementation/scripts/check-pr89-seam-receipt.test.ts b/reference-implementation/scripts/check-pr89-seam-receipt.test.ts new file mode 100644 index 000000000..fb1bb588a --- /dev/null +++ b/reference-implementation/scripts/check-pr89-seam-receipt.test.ts @@ -0,0 +1,206 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; + +import { + buildReceipt, + fileSetDigest, + generateReceipt, + parseCaseOutput, + verifyReceipt, +} from "./check-pr89-seam-receipt.ts"; +import { + CASE_DEFINITIONS, + CASE_EVIDENCE_SCHEMA, + CASE_IDS, + CASE_OUTPUT_SCHEMA, + type CaseEvidence, + type CaseId, + canonicalJson, + digest, + exactSortedValues, + type Json, + RECEIPT_STATIC_PATHS, +} from "./pr89-seam-evidence-contract.ts"; + +const MISSING_EVIDENCE = /case-4 evidence is missing/; +const STALE_RECEIPT = /receipt is stale|implementation_inputs_digest is stale/; +const STALE_BOUND_INPUT = /case_output_digest is stale|fixtures_digest is stale|test_file_digest is stale/; +const SECRET_RESPONSE = /forbidden secret-bearing key/; + +async function writeRepositoryFile(root: string, path: string): Promise<void> { + const absolute = resolve(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, `${path}\n`, "utf8"); +} + +function caseCommand(caseId: CaseId): string[] { + return [ + "node", + "--test", + "--import", + "tsx", + "--test-reporter", + "scripts/test-accounting/node-reporter.ts", + CASE_DEFINITIONS[caseId].testFile, + ]; +} + +async function createEvidenceWorkspace(): Promise<{ + evidenceRoot: string; + receiptPath: string; + repositoryRoot: string; +}> { + const repositoryRoot = await mkdtemp(join(tmpdir(), "pr89-receipt-")); + const evidenceRoot = resolve(repositoryRoot, "generated/evidence"); + const receiptPath = resolve(repositoryRoot, "generated/pr89-receipt.json"); + const sourcePaths = new Set<string>(RECEIPT_STATIC_PATHS); + for (const caseId of CASE_IDS) { + const definition = CASE_DEFINITIONS[caseId]; + sourcePaths.add(definition.testFile); + for (const path of definition.fixturePaths) { + sourcePaths.add(path); + } + for (const path of definition.implementationInputPaths) { + sourcePaths.add(path); + } + } + await Promise.all([...sourcePaths].map((path) => writeRepositoryFile(repositoryRoot, path))); + await mkdir(evidenceRoot, { recursive: true }); + await Promise.all( + CASE_IDS.map(async (caseId) => { + const definition = CASE_DEFINITIONS[caseId]; + const responseEnvelopes = definition.responseEnvelopesRequired + ? [{ error_code: null, name: `${caseId}-stable-response`, status: 200 }] + : []; + const caseOutput = parseCaseOutput( + { + case_id: caseId, + observations: exactSortedValues(definition.observations), + oracle_code: definition.oracleCode, + response_envelopes: responseEnvelopes, + schema: CASE_OUTPUT_SCHEMA, + }, + caseId + ); + const terminalEvents = exactSortedValues(definition.requiredTestNames).map((name) => ({ + name, + status: "pass" as const, + })); + const evidence: CaseEvidence = { + backend: "postgresql", + case_id: caseId, + case_output: caseOutput, + case_output_digest: digest(canonicalJson(caseOutput as unknown as Json)), + command: caseCommand(caseId), + fixtures_digest: await fileSetDigest(repositoryRoot, definition.fixturePaths), + implementation_inputs_digest: await fileSetDigest(repositoryRoot, definition.implementationInputPaths), + oracle_code: definition.oracleCode, + schema: CASE_EVIDENCE_SCHEMA, + status: "pass", + terminal_events: terminalEvents, + terminal_events_digest: digest(canonicalJson(terminalEvents)), + test_file_digest: await fileSetDigest(repositoryRoot, [definition.testFile]), + }; + await writeFile( + resolve(evidenceRoot, `${caseId}.json`), + `${canonicalJson(evidence as unknown as Json)}\n`, + "utf8" + ); + }) + ); + return { evidenceRoot, receiptPath, repositoryRoot }; +} + +test("receipt is generated only from complete executed case evidence", async () => { + const workspace = await createEvidenceWorkspace(); + await generateReceipt(workspace); + await verifyReceipt(workspace); + const receipt = await buildReceipt(workspace); + assert.equal(receipt.schema, "pdpp.pr89.receipt.v2"); + assert.deepEqual(Object.keys(receipt.cases), CASE_IDS); + assert.equal(receipt.assertions.postgresql_races, true); + assert.equal(receipt.assertions.legacy_refresh_state_rejected, true); + assert.equal(receipt.assertions.refresh_family_access_tokens_inactive_on_replay, true); +}); + +test("receipt generation fails when any case evidence is absent", async () => { + const workspace = await createEvidenceWorkspace(); + await rm(resolve(workspace.evidenceRoot, "case-4.json")); + await assert.rejects(() => generateReceipt(workspace), MISSING_EVIDENCE); +}); + +test("receipt verification recomputes tested implementation inputs", async () => { + const workspace = await createEvidenceWorkspace(); + await generateReceipt(workspace); + const [inputPath] = CASE_DEFINITIONS["case-5"].implementationInputPaths; + await writeFile(resolve(workspace.repositoryRoot, inputPath), "changed after execution\n", "utf8"); + await assert.rejects(() => verifyReceipt(workspace), STALE_RECEIPT); +}); + +test("receipt verification recomputes case output, fixture, and test-file digests", async () => { + await Promise.all([ + (async () => { + const workspace = await createEvidenceWorkspace(); + await generateReceipt(workspace); + const evidencePath = resolve(workspace.evidenceRoot, "case-1.json"); + const evidence = JSON.parse(await readFile(evidencePath, "utf8")) as Record<string, unknown>; + evidence.case_output_digest = `sha256:${"0".repeat(64)}`; + await writeFile(evidencePath, `${canonicalJson(evidence as Json)}\n`, "utf8"); + await assert.rejects(() => verifyReceipt(workspace), STALE_BOUND_INPUT); + })(), + (async () => { + const workspace = await createEvidenceWorkspace(); + await generateReceipt(workspace); + const [fixturePath] = CASE_DEFINITIONS["case-1"].fixturePaths; + await writeFile(resolve(workspace.repositoryRoot, fixturePath), "changed fixture\n", "utf8"); + await assert.rejects(() => verifyReceipt(workspace), STALE_BOUND_INPUT); + })(), + (async () => { + const workspace = await createEvidenceWorkspace(); + await generateReceipt(workspace); + await writeFile(resolve(workspace.repositoryRoot, CASE_DEFINITIONS["case-1"].testFile), "changed test\n", "utf8"); + await assert.rejects(() => verifyReceipt(workspace), STALE_BOUND_INPUT); + })(), + ]); +}); + +test("receipt verification rejects a canonical but invented claim", async () => { + const workspace = await createEvidenceWorkspace(); + await generateReceipt(workspace); + const receipt = JSON.parse(await readFile(workspace.receiptPath, "utf8")) as Record<string, unknown>; + (receipt.assertions as Record<string, unknown>).postgresql_races = false; + await writeFile(workspace.receiptPath, `${canonicalJson(receipt as Json)}\n`, "utf8"); + await assert.rejects(() => verifyReceipt(workspace), STALE_RECEIPT); +}); + +test("case output rejects secret-bearing response envelopes", () => { + const definition = CASE_DEFINITIONS["case-2"]; + for (const responseEnvelope of [ + { access_token: "tok_not_allowed" }, + { authorization_header: "Bearer not-allowed" }, + { bearer_token: "not-allowed" }, + { client_password: "not-allowed" }, + { secret: "not-allowed" }, + ]) { + assert.throws( + () => + parseCaseOutput( + { + case_id: "case-2", + observations: exactSortedValues(definition.observations), + oracle_code: definition.oracleCode, + response_envelopes: [responseEnvelope], + schema: CASE_OUTPUT_SCHEMA, + }, + "case-2" + ), + SECRET_RESPONSE + ); + } +}); diff --git a/reference-implementation/scripts/check-pr89-seam-receipt.ts b/reference-implementation/scripts/check-pr89-seam-receipt.ts new file mode 100644 index 000000000..56f111dd3 --- /dev/null +++ b/reference-implementation/scripts/check-pr89-seam-receipt.ts @@ -0,0 +1,474 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { existsSync } from "node:fs"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + CASE_DEFINITIONS, + CASE_EVIDENCE_SCHEMA, + CASE_IDS, + CASE_OUTPUT_SCHEMA, + type CaseDefinition, + type CaseEvidence, + type CaseId, + type CaseOutput, + canonicalJson, + digest, + exactSortedValues, + FIXED_CLOCK, + type Json, + RECEIPT_ASSERTION_CASES, + RECEIPT_COMMAND, + RECEIPT_DECISION_CASES, + RECEIPT_SCHEMA, + RECEIPT_STATIC_PATHS, +} from "./pr89-seam-evidence-contract.ts"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +export const REFERENCE_ROOT = dirname(SCRIPT_DIR); +export const REPOSITORY_ROOT = dirname(REFERENCE_ROOT); +export const SEAM_ROOT = resolve(REFERENCE_ROOT, "test/seam-spike"); +export const ARTIFACT_ROOT = resolve(SEAM_ROOT, "artifacts"); +export const EVIDENCE_ROOT = resolve(ARTIFACT_ROOT, "evidence"); +export const RECEIPT_PATH = resolve(ARTIFACT_ROOT, "pr89-receipt.json"); + +const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/; +const FORBIDDEN_RESPONSE_KEYS = new Set([ + "access_token", + "api_key", + "authorization", + "client_assertion", + "client_secret", + "code", + "code_verifier", + "cookie", + "credentials", + "id_token", + "password", + "refresh_token", + "set-cookie", + "token", +]); +const FORBIDDEN_RESPONSE_KEY_PARTS = new Set(["bearer", "cookie", "credentials", "password", "secret", "token"]); +const TOKEN_VALUE_PATTERN = /\b(?:rt|tok)_[A-Za-z0-9_-]{12,}\b/; +const DYNAMIC_LOCAL_PORT_PATTERN = /https?:\/\/(?:127\.0\.0\.1|localhost):\d+/; + +interface ReceiptCase { + case_output_digest: string; + evidence_digest: string; + fixtures_digest: string; + implementation_inputs_digest: string; + oracle_code: string; + status: "pass"; + terminal_events_digest: string; + test_file_digest: string; +} + +interface Receipt { + assertions: Record<string, true>; + backend: "postgresql"; + cases: Record<CaseId, ReceiptCase>; + clock: string; + command: string; + decisions: Record<string, "pass">; + evidence_tree_digest: string; + fixtures_digest: string; + hardening: { + code_reuse_revocation: "separately_reported"; + dpop: "not_demonstrated"; + keyless_recovery: "deferred"; + refresh_rotation: "pass"; + security_profile_floor: "deferred"; + }; + implementation_inputs_digest: string; + relevant_file_tree_digest: string; + response_envelopes_digest: string; + schema: string; + undecided_common_schemas: true; +} + +function fail(message: string): never { + throw new Error(`PR89 receipt: ${message}`); +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requireExactKeys(record: Record<string, unknown>, expected: readonly string[], path: string): void { + const actual = Object.keys(record).sort(); + const wanted = exactSortedValues(expected); + if (canonicalJson(actual) !== canonicalJson(wanted)) { + fail(`${path} keys must be exactly ${wanted.join(", ")}`); + } +} + +function requireDigest(value: unknown, path: string): asserts value is string { + if (typeof value !== "string" || !SHA256_DIGEST.test(value)) { + fail(`${path} must be a sha256 digest`); + } +} + +function requireRepositoryPath(repositoryRoot: string, path: string): string { + if (!path || path.startsWith("/") || path.split("/").includes("..")) { + fail(`invalid repository-relative path: ${path}`); + } + const absolute = resolve(repositoryRoot, path); + const pathFromRoot = relative(repositoryRoot, absolute); + if (pathFromRoot.startsWith("..") || pathFromRoot === "") { + fail(`path escapes repository: ${path}`); + } + if (!existsSync(absolute)) { + fail(`required evidence input is missing: ${path}`); + } + return absolute; +} + +export async function fileSetDigest(repositoryRoot: string, paths: Iterable<string>): Promise<string> { + const uniquePaths = exactSortedValues(new Set(paths)); + const files = await Promise.all( + uniquePaths.map( + async (path) => [path, digest(await readFile(requireRepositoryPath(repositoryRoot, path)))] as const + ) + ); + return digest(canonicalJson(files as unknown as Json)); +} + +function assertSafeResponseValue(value: Json, path = "response_envelopes"): void { + if (typeof value === "string") { + if (TOKEN_VALUE_PATTERN.test(value) || DYNAMIC_LOCAL_PORT_PATTERN.test(value)) { + fail(`${path} contains a token or dynamic local origin`); + } + return; + } + if (Array.isArray(value)) { + for (const [index, entry] of value.entries()) { + assertSafeResponseValue(entry, `${path}[${index}]`); + } + return; + } + if (value && typeof value === "object") { + for (const [key, entry] of Object.entries(value)) { + const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]+/g, "_"); + const keyParts = normalizedKey.split("_").filter(Boolean); + const secretBearingKey = + FORBIDDEN_RESPONSE_KEYS.has(normalizedKey) || + keyParts.some((part) => FORBIDDEN_RESPONSE_KEY_PARTS.has(part)) || + (keyParts.includes("authorization") && keyParts.includes("header")) || + (keyParts.includes("api") && keyParts.includes("key")) || + (keyParts.includes("client") && keyParts.includes("assertion")); + if (secretBearingKey) { + fail(`${path} contains forbidden secret-bearing key '${key}'`); + } + assertSafeResponseValue(entry, `${path}.${key}`); + } + } +} + +export function parseCaseOutput(value: unknown, caseId: CaseId, definition = CASE_DEFINITIONS[caseId]): CaseOutput { + if (!isRecord(value)) { + fail(`${caseId} output must be an object`); + } + requireExactKeys(value, ["case_id", "observations", "oracle_code", "response_envelopes", "schema"], caseId); + if (value.schema !== CASE_OUTPUT_SCHEMA || value.case_id !== caseId || value.oracle_code !== definition.oracleCode) { + fail(`${caseId} output identity does not match its case definition`); + } + if (!Array.isArray(value.observations) || value.observations.some((entry) => typeof entry !== "string")) { + fail(`${caseId}.observations must be a string array`); + } + const observations = value.observations as string[]; + if ( + new Set(observations).size !== observations.length || + canonicalJson(observations) !== canonicalJson(exactSortedValues(definition.observations)) + ) { + fail(`${caseId}.observations must be the exact sorted case observations`); + } + if (!Array.isArray(value.response_envelopes)) { + fail(`${caseId}.response_envelopes must be an array`); + } + if (definition.responseEnvelopesRequired && value.response_envelopes.length === 0) { + fail(`${caseId}.response_envelopes must contain executed response projections`); + } + assertSafeResponseValue(value.response_envelopes as Json[]); + return value as unknown as CaseOutput; +} + +function expectedCaseCommand(definition: CaseDefinition): string[] { + return [ + "node", + "--test", + "--import", + "tsx", + "--test-reporter", + "scripts/test-accounting/node-reporter.ts", + definition.testFile, + ]; +} + +function evidencePath(caseId: CaseId, evidenceRoot: string): string { + return resolve(evidenceRoot, `${caseId}.json`); +} + +async function readCanonicalJson(path: string, label: string): Promise<{ raw: string; value: unknown }> { + if (!existsSync(path)) { + fail(`${label} is missing: ${path}`); + } + const raw = (await readFile(path, "utf8")).trim(); + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return fail(`${label} is not valid JSON`); + } + if (canonicalJson(value as Json) !== raw) { + fail(`${label} must use canonical lexicographic JSON key ordering`); + } + return { raw, value }; +} + +export async function readCaseEvidence({ + caseId, + evidenceRoot = EVIDENCE_ROOT, + repositoryRoot = REPOSITORY_ROOT, +}: { + caseId: CaseId; + evidenceRoot?: string; + repositoryRoot?: string; +}): Promise<CaseEvidence> { + const definition = CASE_DEFINITIONS[caseId]; + const { raw, value } = await readCanonicalJson(evidencePath(caseId, evidenceRoot), `${caseId} evidence`); + if (!isRecord(value)) { + fail(`${caseId} evidence must be an object`); + } + requireExactKeys( + value, + [ + "backend", + "case_id", + "case_output", + "case_output_digest", + "command", + "fixtures_digest", + "implementation_inputs_digest", + "oracle_code", + "schema", + "status", + "terminal_events", + "terminal_events_digest", + "test_file_digest", + ], + `${caseId} evidence` + ); + if ( + value.schema !== CASE_EVIDENCE_SCHEMA || + value.backend !== "postgresql" || + value.case_id !== caseId || + value.oracle_code !== definition.oracleCode || + value.status !== "pass" + ) { + fail(`${caseId} evidence identity or status is invalid`); + } + if (canonicalJson(value.command as Json) !== canonicalJson(expectedCaseCommand(definition))) { + fail(`${caseId} evidence command does not match the executed case definition`); + } + const output = parseCaseOutput(value.case_output, caseId, definition); + requireDigest(value.case_output_digest, `${caseId}.case_output_digest`); + if (value.case_output_digest !== digest(canonicalJson(output as unknown as Json))) { + fail(`${caseId}.case_output_digest is stale`); + } + if (!Array.isArray(value.terminal_events) || value.terminal_events.length === 0) { + fail(`${caseId}.terminal_events must be a nonempty array`); + } + const events = value.terminal_events.map((entry, index) => { + if (!isRecord(entry)) { + return fail(`${caseId}.terminal_events[${index}] must be an object`); + } + requireExactKeys(entry, ["name", "status"], `${caseId}.terminal_events[${index}]`); + if (typeof entry.name !== "string" || entry.status !== "pass") { + return fail(`${caseId}.terminal_events[${index}] is not a passing named test`); + } + return { name: entry.name, status: "pass" as const }; + }); + const eventNames = events.map(({ name }) => name); + if ( + new Set(eventNames).size !== eventNames.length || + canonicalJson(eventNames) !== canonicalJson(exactSortedValues(eventNames)) + ) { + fail(`${caseId}.terminal_events must have unique lexicographically sorted names`); + } + for (const requiredName of definition.requiredTestNames) { + if (!eventNames.includes(requiredName)) { + fail(`${caseId} did not execute required test: ${requiredName}`); + } + } + requireDigest(value.terminal_events_digest, `${caseId}.terminal_events_digest`); + if (value.terminal_events_digest !== digest(canonicalJson(events))) { + fail(`${caseId}.terminal_events_digest is stale`); + } + const expectedTestDigest = await fileSetDigest(repositoryRoot, [definition.testFile]); + requireDigest(value.test_file_digest, `${caseId}.test_file_digest`); + if (value.test_file_digest !== expectedTestDigest) { + fail(`${caseId}.test_file_digest is stale`); + } + const expectedImplementationDigest = await fileSetDigest(repositoryRoot, definition.implementationInputPaths); + requireDigest(value.implementation_inputs_digest, `${caseId}.implementation_inputs_digest`); + if (value.implementation_inputs_digest !== expectedImplementationDigest) { + fail(`${caseId}.implementation_inputs_digest is stale`); + } + const expectedFixturesDigest = await fileSetDigest(repositoryRoot, definition.fixturePaths); + requireDigest(value.fixtures_digest, `${caseId}.fixtures_digest`); + if (value.fixtures_digest !== expectedFixturesDigest) { + fail(`${caseId}.fixtures_digest is stale`); + } + rejectForbiddenMarkers(raw); + return value as unknown as CaseEvidence; +} + +function allCasePaths(selector: (definition: CaseDefinition) => readonly string[]): string[] { + return CASE_IDS.flatMap((caseId) => selector(CASE_DEFINITIONS[caseId])); +} + +function relevantPaths(): string[] { + return exactSortedValues([ + ...RECEIPT_STATIC_PATHS, + ...allCasePaths((definition) => definition.fixturePaths), + ...allCasePaths((definition) => definition.implementationInputPaths), + ...CASE_IDS.map((caseId) => CASE_DEFINITIONS[caseId].testFile), + ]); +} + +export async function buildReceipt({ + evidenceRoot = EVIDENCE_ROOT, + repositoryRoot = REPOSITORY_ROOT, +}: { + evidenceRoot?: string; + repositoryRoot?: string; +} = {}): Promise<Receipt> { + const evidenceEntries = await Promise.all( + CASE_IDS.map(async (caseId) => [caseId, await readCaseEvidence({ caseId, evidenceRoot, repositoryRoot })] as const) + ); + const evidence = Object.fromEntries(evidenceEntries) as Record<CaseId, CaseEvidence>; + const cases = Object.fromEntries( + CASE_IDS.map((caseId) => { + const row = evidence[caseId]; + return [ + caseId, + { + case_output_digest: row.case_output_digest, + evidence_digest: digest(canonicalJson(row as unknown as Json)), + fixtures_digest: row.fixtures_digest, + implementation_inputs_digest: row.implementation_inputs_digest, + oracle_code: row.oracle_code, + status: "pass" as const, + terminal_events_digest: row.terminal_events_digest, + test_file_digest: row.test_file_digest, + }, + ]; + }) + ) as Record<CaseId, ReceiptCase>; + const assertions = Object.fromEntries( + Object.entries(RECEIPT_ASSERTION_CASES).map(([claim, requiredCases]) => { + for (const caseId of requiredCases) { + if (evidence[caseId].status !== "pass") { + fail(`${claim} lacks passing ${caseId} evidence`); + } + } + return [claim, true]; + }) + ) as Record<string, true>; + const decisions = Object.fromEntries( + Object.entries(RECEIPT_DECISION_CASES).map(([decision, requiredCases]) => { + for (const caseId of requiredCases) { + if (evidence[caseId].status !== "pass") { + fail(`${decision} lacks passing ${caseId} evidence`); + } + } + return [decision, "pass"]; + }) + ) as Record<string, "pass">; + return { + assertions, + backend: "postgresql", + cases, + clock: FIXED_CLOCK, + command: RECEIPT_COMMAND, + decisions, + evidence_tree_digest: digest( + canonicalJson(Object.fromEntries(CASE_IDS.map((caseId) => [caseId, cases[caseId].evidence_digest]))) + ), + fixtures_digest: await fileSetDigest( + repositoryRoot, + allCasePaths((definition) => definition.fixturePaths) + ), + hardening: { + code_reuse_revocation: "separately_reported", + dpop: "not_demonstrated", + keyless_recovery: "deferred", + refresh_rotation: "pass", + security_profile_floor: "deferred", + }, + implementation_inputs_digest: await fileSetDigest( + repositoryRoot, + allCasePaths((definition) => definition.implementationInputPaths) + ), + relevant_file_tree_digest: await fileSetDigest(repositoryRoot, relevantPaths()), + response_envelopes_digest: digest( + canonicalJson( + Object.fromEntries(CASE_IDS.map((caseId) => [caseId, evidence[caseId].case_output.response_envelopes])) + ) + ), + schema: RECEIPT_SCHEMA, + undecided_common_schemas: true, + }; +} + +export async function generateReceipt({ + evidenceRoot = EVIDENCE_ROOT, + receiptPath = RECEIPT_PATH, + repositoryRoot = REPOSITORY_ROOT, +}: { + evidenceRoot?: string; + receiptPath?: string; + repositoryRoot?: string; +} = {}): Promise<void> { + const receipt = await buildReceipt({ evidenceRoot, repositoryRoot }); + await mkdir(dirname(receiptPath), { recursive: true }); + const temporaryPath = `${receiptPath}.tmp`; + await writeFile(temporaryPath, `${canonicalJson(receipt as unknown as Json)}\n`, "utf8"); + await rename(temporaryPath, receiptPath); +} + +export async function verifyReceipt({ + evidenceRoot = EVIDENCE_ROOT, + receiptPath = RECEIPT_PATH, + repositoryRoot = REPOSITORY_ROOT, +}: { + evidenceRoot?: string; + receiptPath?: string; + repositoryRoot?: string; +} = {}): Promise<void> { + const { raw, value } = await readCanonicalJson(receiptPath, "receipt"); + const expected = await buildReceipt({ evidenceRoot, repositoryRoot }); + const canonicalExpected = canonicalJson(expected as unknown as Json); + if (raw !== canonicalExpected || canonicalJson(value as Json) !== canonicalExpected) { + fail("receipt is stale or contains a claim not derived from current case evidence"); + } + rejectForbiddenMarkers(raw); +} + +function rejectForbiddenMarkers(serialized: string): void { + if (serialized.includes('"duplicated_rights"') || serialized.includes('"rights_duplicated"')) { + fail("receipt must not include duplicated rights markers"); + } + if (serialized.includes('"in_process_fallback":true') || serialized.includes('"fallback":"in_process"')) { + fail("receipt must not include in-process fallback markers"); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await verifyReceipt(); + process.stdout.write("PR89 receipt is current and derived from executed case evidence.\n"); +} diff --git a/reference-implementation/scripts/file-process-watchdog.ts b/reference-implementation/scripts/file-process-watchdog.ts new file mode 100644 index 000000000..eb507f3ee --- /dev/null +++ b/reference-implementation/scripts/file-process-watchdog.ts @@ -0,0 +1,103 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Bound a child process by output inactivity and by an independent absolute + * per-file deadline. + * + * A test file can be finite but slower than the inactivity budget when the + * machine is loaded. Output keeps that budget alive. The hard deadline still + * bounds a continuously chatty process that never terminates. + */ + +const WATCHDOG_TICK_MS = 1000; + +type Timer = ReturnType<typeof setInterval>; + +export type FileProcessTimeoutReason = "hard" | "idle"; + +export interface FileProcessWatchdog { + clear: () => void; + markProgress: () => void; + timeoutReason: () => FileProcessTimeoutReason | undefined; +} + +export interface FileProcessWatchdogOptions { + cancel?: (timer: Timer) => void; + hardDeadlineMs: number; + idleBudgetMs: number; + kill: () => void; + now?: () => number; + schedule?: (callback: () => void, delayMs: number) => Timer; +} + +function assertValidDuration(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive safe integer number of milliseconds`); + } +} + +/** Start a per-file inactivity and hard-deadline watchdog. */ +export function startFileProcessWatchdog({ + cancel = clearInterval, + hardDeadlineMs, + idleBudgetMs, + kill, + now = Date.now, + schedule = setInterval, +}: FileProcessWatchdogOptions): FileProcessWatchdog { + assertValidDuration("hardDeadlineMs", hardDeadlineMs); + assertValidDuration("idleBudgetMs", idleBudgetMs); + if (hardDeadlineMs < idleBudgetMs) { + throw new Error("hardDeadlineMs must be greater than or equal to idleBudgetMs"); + } + + const startedAt = now(); + let active = true; + let lastProgressAt = startedAt; + let reason: FileProcessTimeoutReason | undefined; + let timer: Timer | undefined; + + const stopTimer = () => { + if (timer !== undefined) { + cancel(timer); + timer = undefined; + } + }; + + const check = () => { + if (!active) { + return; + } + const currentTime = now(); + if (currentTime - startedAt >= hardDeadlineMs) { + reason = "hard"; + } else if (currentTime - lastProgressAt >= idleBudgetMs) { + reason = "idle"; + } else { + return; + } + active = false; + stopTimer(); + kill(); + }; + + timer = schedule(check, Math.max(1, Math.min(WATCHDOG_TICK_MS, idleBudgetMs, hardDeadlineMs))); + timer.unref?.(); + + return { + clear: () => { + if (!active) { + return; + } + active = false; + stopTimer(); + }, + markProgress: () => { + if (active) { + lastProgressAt = now(); + } + }, + timeoutReason: () => reason, + }; +} diff --git a/reference-implementation/scripts/migrate-storage/record-synthesis.ts b/reference-implementation/scripts/migrate-storage/record-synthesis.ts index ee3c0449d..5dcc118cc 100644 --- a/reference-implementation/scripts/migrate-storage/record-synthesis.ts +++ b/reference-implementation/scripts/migrate-storage/record-synthesis.ts @@ -29,17 +29,17 @@ export interface Manifest { export type RecordJson = string | Record<string, unknown> | null | undefined; const KEY_SEPARATOR = "\x00"; // NUL byte separator for composite keys -const SAFE_JSON_FIELD_RE = /^[A-Za-z0-9_]+$/; /** - * Validate a JSON field name (alphanumeric + underscore only). - * Mirrors safeJsonField from postgres-records.js line 192-194. + * A SourceDeclaration cursor field names one literal top-level JSON key. + * It is data, not an identifier: punctuation, Unicode, and whitespace are + * valid key characters. * * @param field * @returns */ -function safeJsonField(field: string | null | undefined): string | null { - if (!(field && SAFE_JSON_FIELD_RE.test(field))) { +function literalTopLevelField(field: string | null | undefined): string | null { + if (typeof field !== "string" || field.length === 0) { return null; } return field; @@ -194,7 +194,7 @@ export function deriveCursorValue( recordJson: RecordJson ): string | null { // Get cursor field from manifest - const cursorField = safeJsonField(streamMeta?.cursor_field); + const cursorField = literalTopLevelField(streamMeta?.cursor_field); // If no cursor field declared, return null if (!cursorField) { diff --git a/reference-implementation/scripts/pr89-seam-evidence-contract.ts b/reference-implementation/scripts/pr89-seam-evidence-contract.ts new file mode 100644 index 000000000..54d8f4101 --- /dev/null +++ b/reference-implementation/scripts/pr89-seam-evidence-contract.ts @@ -0,0 +1,492 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +export const CASE_OUTPUT_SCHEMA = "pdpp.pr89.case-output.v1"; +export const CASE_EVIDENCE_SCHEMA = "pdpp.pr89.case-evidence.v1"; +export const RECEIPT_SCHEMA = "pdpp.pr89.receipt.v2"; +export const RECEIPT_COMMAND = "pnpm --filter pdpp-reference-implementation test:seam:pr89 -- --backend postgresql"; +export const FIXED_CLOCK = "2026-08-11T12:00:00Z"; + +export type CaseOracle = + | "authorization_state.unsupported_legacy_shape" + | "context_resolved" + | "durable_handoff" + | "equal" + | "gnap_map" + | "partial_approval" + | "races_and_refresh" + | "response_only"; + +export interface CaseOutput { + case_id: CaseId; + observations: string[]; + oracle_code: CaseOracle; + response_envelopes: Json[]; + schema: typeof CASE_OUTPUT_SCHEMA; +} + +export interface TerminalTestEvent { + name: string; + status: "pass"; +} + +export interface CaseEvidence { + backend: "postgresql"; + case_id: CaseId; + case_output: CaseOutput; + case_output_digest: string; + command: string[]; + fixtures_digest: string; + implementation_inputs_digest: string; + oracle_code: CaseOracle; + schema: typeof CASE_EVIDENCE_SCHEMA; + status: "pass"; + terminal_events: TerminalTestEvent[]; + terminal_events_digest: string; + test_file_digest: string; +} + +export interface CaseDefinition { + fixturePaths: readonly string[]; + implementationInputPaths: readonly string[]; + observations: readonly string[]; + oracleCode: CaseOracle; + outputRequired: boolean; + requiredTestNames: readonly string[]; + responseEnvelopesRequired: boolean; + testFile: string; +} + +export const CASE_DEFINITIONS = { + "case-1": { + fixturePaths: [ + "reference-implementation/test/seam-spike/fixtures/pr89/grant-v01.json", + "reference-implementation/test/seam-spike/fixtures/pr89/rar-approved.json", + "reference-implementation/test/seam-spike/fixtures/pr89/source.json", + ], + implementationInputPaths: [ + "reference-implementation/server/source-approved-authorization.ts", + "packages/reference-contract/src/public/source.ts", + "reference-implementation/server/core-source-authorization.ts", + "reference-implementation/server/source-declaration.ts", + ], + observations: [ + "approved_authorization_equal", + "binding_fields_excluded", + "instance_and_field_rows_observed", + "invalid_mutations_rejected", + ], + oracleCode: "equal", + outputRequired: true, + requiredTestNames: [ + "persisted grant and approved RAR project to equal neutral authorization", + "provenance variants stay outside equality and mismatches fail before projection", + "invalid and widening mutations return stable authorization codes", + ], + responseEnvelopesRequired: false, + testFile: "reference-implementation/test/seam-spike/pr89-case-1-source-contract.test.ts", + }, + "case-2": { + fixturePaths: [ + "reference-implementation/test/seam-spike/fixtures/pr89/rar-approved.json", + "reference-implementation/test/seam-spike/fixtures/pr89/rar-request-invalid.json", + "reference-implementation/test/seam-spike/fixtures/pr89/rar-request.json", + ], + implementationInputPaths: [ + "packages/reference-contract/src/public/source.ts", + "reference-implementation/operations/as-consent-decision/index.ts", + "reference-implementation/server/auth.ts", + "reference-implementation/server/source-approved-authorization.ts", + "reference-implementation/server/core-source-authorization.ts", + "reference-implementation/server/routes/as-authorize.ts", + "reference-implementation/server/routes/as-consent.ts", + "reference-implementation/server/routes/as-oauth.ts", + "reference-implementation/server/source-declaration.ts", + ], + observations: [ + "declined_stream_unqueryable", + "partial_approval_preserved", + "policy_terms_preserved", + "source_error_mapped", + ], + oracleCode: "partial_approval", + outputRequired: true, + requiredTestNames: [ + "real authorization-code PKCE flow preserves narrowed approval and policy terms", + "invalid Source selection maps to invalid_authorization_details", + ], + responseEnvelopesRequired: true, + testFile: "reference-implementation/test/seam-spike/pr89-case-2-partial-approval.test.ts", + }, + "case-3": { + fixturePaths: [ + "reference-implementation/test/seam-spike/fixtures/pr89/rar-request.json", + "reference-implementation/test/seam-spike/fixtures/pr89/source.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/client-mismatch.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/expired.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/field-mismatch.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/grant-mismatch.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/inactive.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/instance-mismatch.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/rights-missing.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/source-mismatch.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/stale-cache.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/subject-mismatch.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-audience.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-context-kind.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-credentials.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-issuer.json", + "reference-implementation/test/seam-spike/fixtures/pr89/introspection/valid.json", + ], + implementationInputPaths: [ + "reference-implementation/server/core-source-authorization.ts", + "reference-implementation/operations/as-introspect/index.ts", + "reference-implementation/server/auth.ts", + "reference-implementation/server/index.ts", + "reference-implementation/server/introspection-http.ts", + "reference-implementation/server/routes/as-oauth.ts", + "reference-implementation/server/routes/rs-read.ts", + "reference-implementation/server/source-approved-authorization.ts", + "reference-implementation/server/source-introspection-context.ts", + "reference-implementation/test/seam-spike/pr89-oauth-harness.ts", + ], + observations: [ + "authenticated_http_introspection", + "complete_context_resolved", + "mutation_matrix_rejected", + "one_http_introspection_no_fallback", + ], + oracleCode: "context_resolved", + outputRequired: true, + requiredTestNames: ["authenticated HTTP introspection resolves context and rejects the fixed mutation matrix"], + responseEnvelopesRequired: true, + testFile: "reference-implementation/test/seam-spike/pr89-case-3-introspection-context.test.ts", + }, + "case-4": { + fixturePaths: [ + "reference-implementation/test/seam-spike/fixtures/pr89/rar-request.json", + "reference-implementation/test/seam-spike/fixtures/pr89/records.json", + "reference-implementation/test/seam-spike/fixtures/pr89/source.json", + ], + implementationInputPaths: [ + "reference-implementation/server/core-source-authorization.ts", + "reference-implementation/operations/as-introspect/index.ts", + "reference-implementation/server/auth.ts", + "reference-implementation/server/index.ts", + "reference-implementation/server/introspection-http.ts", + "reference-implementation/server/record-filters.ts", + "reference-implementation/server/records.ts", + "reference-implementation/server/routes/as-oauth.ts", + "reference-implementation/server/routes/rs-read.ts", + "reference-implementation/server/source-approved-authorization.ts", + "reference-implementation/server/source-introspection-context.ts", + "reference-implementation/test/seam-spike/pr89-oauth-harness.ts", + ], + observations: ["allowed_matrix_passed", "as_disabled", "denied_matrix_passed", "response_only_enforcement"], + oracleCode: "response_only", + outputRequired: true, + requiredTestNames: ["captured introspection context enforces the response-only request matrix with AS disabled"], + responseEnvelopesRequired: true, + testFile: "reference-implementation/test/seam-spike/pr89-case-4-response-only.test.ts", + }, + "case-5": { + fixturePaths: ["reference-implementation/test/seam-spike/fixtures/pr89/legacy-grant-v01.bytes"], + implementationInputPaths: [ + "reference-implementation/server/auth.ts", + "reference-implementation/server/credential-response-cache.ts", + "reference-implementation/server/db.ts", + "reference-implementation/server/postgres-storage.ts", + "reference-implementation/server/queries/auth/oauth-authorization-codes/consume-code.sql", + "reference-implementation/server/queries/auth/oauth-authorization-codes/get-by-code.sql", + "reference-implementation/server/queries/auth/oauth-authorization-codes/get-by-device-code.sql", + "reference-implementation/server/queries/auth/oauth-authorization-codes/issue-for-device-code.sql", + "reference-implementation/server/queries/auth/oauth-authorization-codes/issue-package-for-device-code.sql", + "reference-implementation/server/queries/auth/grant-package-members/list-all-by-package.sql", + "reference-implementation/server/queries/auth/oauth-refresh-tokens/get-by-token.sql", + "reference-implementation/server/queries/auth/oauth-refresh-tokens/insert.sql", + "reference-implementation/server/queries/auth/oauth-refresh-tokens/revoke-family.sql", + "reference-implementation/server/queries/auth/oauth-refresh-tokens/supersede-active.sql", + "reference-implementation/server/queries/auth/tokens/get-introspection.sql", + "reference-implementation/server/queries/auth/tokens/insert-refresh-client.sql", + "reference-implementation/server/queries/auth/tokens/insert-refresh-mcp-package.sql", + "reference-implementation/server/queries/auth/tokens/link-refresh-family.sql", + "reference-implementation/server/queries/auth/tokens/revoke-by-refresh-family.sql", + "reference-implementation/server/queries/index.ts", + "reference-implementation/server/routes/as-oauth.ts", + "reference-implementation/test/oauth-code-delivery-atomicity.test.ts", + "reference-implementation/test/grant-package-postgres-path.test.ts", + "reference-implementation/test/hosted-mcp-oauth.test.ts", + "reference-implementation/test/token-refresh-postgres-path.test.ts", + ], + observations: [ + "authorization_code_race", + "family_access_bearers_inactive", + "fresh_authorization_required", + "legacy_unlinked_refresh_state_rejected", + "null_exp_omitted", + "package_refresh_replay_contained", + "refresh_access_expiry_bounded", + "refresh_family_replay_revoked", + "replay_containment_atomic", + "single_use_race", + "single_use_refresh_omitted", + "supersede_failure_atomic", + ], + oracleCode: "races_and_refresh", + outputRequired: false, + requiredTestNames: ["authorization and refresh lifecycle portfolio passes on PostgreSQL"], + responseEnvelopesRequired: false, + testFile: "reference-implementation/test/seam-spike/pr89-case-5-lifecycle.test.ts", + }, + "case-6": { + fixturePaths: ["reference-implementation/test/seam-spike/fixtures/pr89/legacy-grant-v01.bytes"], + implementationInputPaths: [ + "reference-implementation/operations/as-introspect/index.ts", + "reference-implementation/server/auth.ts", + "reference-implementation/server/index.ts", + "reference-implementation/server/introspection-http.ts", + "reference-implementation/server/routes/as-oauth.ts", + ], + observations: [ + "before_introspection_or_route", + "fresh_authorization_required", + "legacy_bytes_rejected", + "no_reconstruction", + ], + oracleCode: "authorization_state.unsupported_legacy_shape", + outputRequired: false, + requiredTestNames: [ + "pre-contract persisted bytes are rejected by the current grant reader", + "legacy persisted grant state fails before the SQLite RS route", + ], + responseEnvelopesRequired: false, + testFile: "reference-implementation/test/persisted-authorization-state-boundary.test.ts", + }, + "case-7": { + fixturePaths: [ + "reference-implementation/test/seam-spike/fixtures/pr89/gnap/approved.json", + "reference-implementation/test/seam-spike/fixtures/pr89/gnap/partial.json", + "reference-implementation/test/seam-spike/fixtures/pr89/gnap/unknown-mandatory.json", + ], + implementationInputPaths: [], + observations: ["full_round_trip", "not_demonstrated_not_passed", "partial_narrowing", "unknown_mandatory_rejected"], + oracleCode: "gnap_map", + outputRequired: false, + requiredTestNames: [ + "GNAP approved rights round-trip without changing neutral rights", + "GNAP partial approval is represented as narrowed neutral rights", + "GNAP rejects unknown mandatory members", + "GNAP control map does not count not-demonstrated controls as passed", + ], + responseEnvelopesRequired: false, + testFile: "reference-implementation/test/seam-spike/pr89-gnap-map.test.ts", + }, + "case-8": { + fixturePaths: [], + implementationInputPaths: [ + "reference-implementation/operations/as-device-decision/index.ts", + "reference-implementation/operations/as-consent-decision/index.ts", + "reference-implementation/operations/as-consent-exchange/index.ts", + "reference-implementation/server/auth.ts", + "reference-implementation/server/db.ts", + "reference-implementation/server/postgres-storage.ts", + "reference-implementation/server/queries/auth/agent-connect-attempts/delete-by-id.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-by-id.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-historic-page.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-if-consent-terminal.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/get-expired-by-request-uri.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/get-by-id.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/insert-if-consent-pending.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/insert.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/list-expired-pending.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/list-expired-tombstones.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/mark-approved.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/mark-expired-by-id.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/mark-failed.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/prune.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/recover-approved.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/revoke-token-if-no-live-sibling.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/revoke-token.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/set-expires-at-by-id.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/set-response-json.sql", + "reference-implementation/server/queries/auth/agent-connect-attempts/token-active.sql", + "reference-implementation/server/queries/auth/consent-exchange-codes/get-for-redemption.sql", + "reference-implementation/server/queries/auth/consent-exchange-codes/insert.sql", + "reference-implementation/server/queries/auth/consent-exchange-codes/mark-redeemed.sql", + "reference-implementation/server/queries/auth/grants/get-for-revocation.sql", + "reference-implementation/server/queries/auth/pending-consents/mark-expired-if-due.sql", + "reference-implementation/server/queries/index.ts", + "reference-implementation/server/routes/as-agent-connect.ts", + "reference-implementation/server/routes/as-consent.ts", + "reference-implementation/test/agent-cli.test.ts", + "reference-implementation/test/as-device-decision-outcome-pure.test.ts", + "reference-implementation/test/auth-consent-device-postgres-path.test.ts", + "reference-implementation/test/batch-consent-per-source-gate.test.ts", + "reference-implementation/test/owner-device-approval-atomicity.test.ts", + "reference-implementation/test/security-consent-token-handoff.test.ts", + ], + observations: [ + "approve_deny_race_single_terminal_outcome", + "approval_commit_handoff_resume", + "approved_after_expiry_bearer_revoked", + "approved_cleanup_race_bearer_revoked", + "approved_expiry_cas_bearer_revoked", + "approved_crash_expiry_reconciled", + "approved_crash_prune_reconciled", + "credential_response_201_registration_no_store", + "credential_response_200_approved_no_store", + "credential_response_202_pending_bounded", + "credential_response_400_expired_bounded", + "credential_response_401_invalid_polling_code_bounded", + "credential_response_403_denied_bounded", + "denied_consent_projects_to_polling", + "denied_consent_reconciles_after_completion_failure", + "expired_consent_projects_to_polling", + "postgres_denied_consent_recovery", + "expired_bearer_refused", + "invalid_bearer_redacted", + "owner_device_concurrency_bound", + "owner_device_cross_subject_hidden", + "owner_device_rollback_atomic", + "package_handoff_and_revocation", + "postgresql_concurrent_redemption", + "response_loss_retained_after_unrelated_registration", + "revoked_bearer_refused", + "single_use_bound_recovery", + "sqlite_restart_and_response_loss", + ], + oracleCode: "durable_handoff", + outputRequired: false, + requiredTestNames: [ + "agent-cli: approved-after-expiry revokes bearer and expired bearer is refused", + "agent-cli: cache headers reject invalid bearer without token disclosure", + "agent-connect: denial response is bounded", + "agent-connect: denial is durable across approval_id and completion failure", + "agent-connect: live PostgreSQL denial is durable across approval_id and restart", + "agent-connect: registration response is cache-safe", + "agent-cli: cleanup/approval race revokes committed token", + "agent-cli: crash-completed expiry and prune revoke committed approvals", + "agent-cli: crash recovery from committed pending approval", + "agent-cli: live PostgreSQL crash expiry/prune and response-loss replay", + "agent-cli: live PostgreSQL approved expiry and revocation fail closed before delivery", + "agent-cli: response-loss replay survives unrelated registration", + "agent-cli: revoked bearer is refused before delivery", + "auth consent device PostgreSQL: concurrent redemption and package revocation", + "batch consent: package handoff and revocation are durable", + "consent-exchange: SQLite restart, single-use, and response-loss recovery", + "owner-device-approval-atomicity: rollback, owner concurrency, and cross-subject recovery", + "terminal decisions: SQLite approval and denial arbitrate without contradictory evidence", + "terminal decisions: live PostgreSQL approval and denial arbitrate atomically", + ], + responseEnvelopesRequired: false, + testFile: "reference-implementation/test/seam-spike/pr89-case-8-durable-handoff.test.ts", + }, +} as const satisfies Record<string, CaseDefinition>; + +export type CaseId = keyof typeof CASE_DEFINITIONS; + +export const CASE_IDS = Object.keys(CASE_DEFINITIONS).sort() as CaseId[]; +export const CASE_EXECUTION_ORDER: readonly CaseId[] = [ + "case-5", + "case-6", + "case-7", + "case-8", + "case-1", + "case-2", + "case-3", + "case-4", +]; + +export const RECEIPT_ASSERTION_CASES = { + authenticated_http_introspection: ["case-3"], + durable_post_approval_handoff: ["case-8"], + fresh_authorization_required: ["case-5", "case-6"], + legacy_refresh_state_rejected: ["case-5"], + no_in_process_fallback: ["case-3", "case-4"], + postgresql_races: ["case-5"], + refresh_family_access_tokens_inactive_on_replay: ["case-5"], + refresh_family_revoked_on_replay: ["case-5"], + response_only_enforcement: ["case-4"], +} as const satisfies Record<string, readonly CaseId[]>; + +export const RECEIPT_DECISION_CASES = { + approved_authorization_shape: ["case-1"], + authorization_context_composition: ["case-3", "case-4"], + binding_separation: ["case-1", "case-2"], +} as const satisfies Record<string, readonly CaseId[]>; + +export const RECEIPT_STATIC_PATHS = [ + ".github/workflows/pr89-seam-receipt.yml", + "design-notes/seam-spike/corpus.md", + "openspec/changes/harden-pdpp-authorization-and-0-1-migration/design.md", + "openspec/changes/harden-pdpp-authorization-and-0-1-migration/proposal.md", + "openspec/changes/harden-pdpp-authorization-and-0-1-migration/specs/pdpp-authorization-hardening/spec.md", + "openspec/changes/harden-pdpp-authorization-and-0-1-migration/tasks.md", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "reference-implementation/package.json", + "reference-implementation/scripts/check-pr89-seam-receipt.test.ts", + "reference-implementation/scripts/check-pr89-seam-receipt.ts", + "reference-implementation/scripts/pr89-seam-evidence-contract.ts", + "reference-implementation/scripts/run-pr89-seam.ts", + "reference-implementation/test/seam-spike/pr89-case-output.ts", + "reference-implementation/test/seam-spike/pr89-case-5-lifecycle.test.ts", + "reference-implementation/test/seam-spike/artifacts/.gitignore", + "reference-implementation/test/seam-spike/pr89-receipt.schema.json", + "reference-implementation/test/auth-consent-device-postgres-path.test.ts", + "reference-implementation/test/as-oauth-token-cache-headers.test.ts", + "reference-implementation/test/batch-consent-per-source-gate.test.ts", + "reference-implementation/test/security-consent-token-handoff.test.ts", + "scripts/test-accounting/node-reporter.ts", + "scripts/test-accounting/receipt.ts", + "test-accounting.manifest.json", +] as const; + +function compareStrings(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +export function canonicalJson(value: Json): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (value !== null && typeof value === "object") { + return `{${Object.keys(value) + .sort(compareStrings) + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key] as Json)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function digest(value: string | Buffer): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +export function exactSortedValues(values: Iterable<string>): string[] { + return [...values].sort(compareStrings); +} + +export async function writeCaseOutput( + output: CaseOutput, + outputPath = process.env.PDPP_PR89_CASE_OUTPUT_PATH +): Promise<void> { + if (!outputPath) { + return; + } + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${canonicalJson(output as unknown as Json)}\n`, "utf8"); +} diff --git a/reference-implementation/scripts/quality-ratchet/mass-baseline.json b/reference-implementation/scripts/quality-ratchet/mass-baseline.json index 96e53b916..b03699149 100644 --- a/reference-implementation/scripts/quality-ratchet/mass-baseline.json +++ b/reference-implementation/scripts/quality-ratchet/mass-baseline.json @@ -22,6 +22,7 @@ "runtime/detail-gap-paging.ts": 24, "runtime/done-validators.ts": 10, "runtime/index.ts": 408, + "runtime/ingest-failure.ts": 1, "runtime/ingest-failures.ts": 2, "runtime/owner-state.ts": 12, "runtime/pipe-errors.ts": 1, @@ -45,7 +46,7 @@ "server/aggregate-request-validation.ts": 16, "server/attention-urgency.ts": 4, "server/auth-middleware.ts": 18, - "server/auth.ts": 384, + "server/auth.ts": 441, "server/auto-enroll-eligible-schedules.ts": 9, "server/cimd.ts": 15, "server/client-event-delivery-worker.ts": 35, @@ -54,7 +55,7 @@ "server/concurrency.ts": 1, "server/connection-activation-schedules.ts": 2, "server/connection-id-request.ts": 9, - "server/connection-identity.ts": 16, + "server/connection-identity.ts": 13, "server/connection-setup-plan.ts": 24, "server/connector-coverage-policy.ts": 18, "server/connector-gap-classification.ts": 23, @@ -64,11 +65,12 @@ "server/connector-maintenance-sweep.ts": 18, "server/connector-manifest-validation.ts": 111, "server/connector-outbox-axis.ts": 20, - "server/connector-schema-builder.ts": 9, + "server/connector-schema-builder.ts": 8, "server/connector-summary-evidence-engine.ts": 70, "server/connector-summary-read-model.ts": 102, "server/connector-summary-reconcile-observability.ts": 13, "server/contract-validation.ts": 4, + "server/core-source-authorization.ts": 32, "server/dataset-summary-read-model.ts": 56, "server/db-sqlite-busy-retry.ts": 31, "server/db.ts": 116, @@ -77,14 +79,13 @@ "server/device-ingest-attempt-context.ts": 1, "server/fleet-health.ts": 6, "server/freshness.ts": 10, - "server/grant-package-lifecycle.ts": 100, "server/hosted-mcp-selection.ts": 11, "server/hosted-ui.ts": 9, "server/inbox.ts": 2, "server/index.ts": 380, "server/local-transformer-child.ts": 1, "server/local-transformer-executor.ts": 12, - "server/manifest-resolution.ts": 30, + "server/manifest-resolution.ts": 13, "server/metadata.ts": 36, "server/neko-surface-allocator-server.ts": 55, "server/notification-policy.ts": 2, @@ -93,7 +94,7 @@ "server/owner-csrf.ts": 4, "server/owner-exposure-posture.ts": 14, "server/owner-session.ts": 7, - "server/package-rs-client.ts": 122, + "server/package-rs-client.ts": 121, "server/pending-pressure-gap-map.ts": 1, "server/polyfill-manifest-reconcile.ts": 17, "server/postgres-records.ts": 207, @@ -101,14 +102,14 @@ "server/postgres-storage.ts": 100, "server/queries/index.ts": 23, "server/record-aggregation.ts": 5, - "server/record-expand-helpers.ts": 12, - "server/record-filters.ts": 50, - "server/record-ingest-semantic-time.ts": 13, + "server/record-expand-helpers.ts": 14, + "server/record-filters.ts": 62, + "server/record-ingest-semantic-time.ts": 12, "server/record-projection-read-model.ts": 6, "server/record-query-helpers.ts": 22, "server/record-version-stats.ts": 36, "server/records.ts": 469, - "server/ref-control.ts": 217, + "server/ref-control.ts": 221, "server/ref-record-utils.ts": 7, "server/reference-root-landing.ts": 4, "server/reference-topology.ts": 10, @@ -116,9 +117,9 @@ "server/retrieval-startup-backfill.ts": 7, "server/routes/_owner-connection-helpers.ts": 1, "server/routes/as-agent-connect.ts": 29, - "server/routes/as-authorize.ts": 36, - "server/routes/as-consent-ui-helpers.ts": 59, - "server/routes/as-consent.ts": 57, + "server/routes/as-authorize.ts": 40, + "server/routes/as-consent-ui-helpers.ts": 54, + "server/routes/as-consent.ts": 84, "server/routes/as-dcr.ts": 6, "server/routes/as-device-ui.ts": 13, "server/routes/as-grant-revoke.ts": 12, @@ -151,7 +152,7 @@ "server/routes/ref-static-secret-draft-connection.ts": 21, "server/routes/ref-static-secret-setup-status.ts": 9, "server/routes/root-and-discovery.ts": 27, - "server/routes/rs-mutation.ts": 76, + "server/routes/rs-mutation.ts": 87, "server/routes/rs-read.ts": 119, "server/routes/run-cancel.ts": 7, "server/routes/run-interaction.ts": 29, @@ -164,9 +165,10 @@ "server/schema-coercion.ts": 10, "server/search-fanout.ts": 4, "server/search-hybrid.ts": 7, - "server/search-semantic.ts": 255, + "server/search-semantic.ts": 249, "server/search.ts": 82, - "server/source-descriptor.ts": 3, + "server/source-declaration.ts": 6, + "server/source-descriptor.ts": 5, "server/ssrf-guard.ts": 29, "server/storage-utils.ts": 9, "server/stores/acquisition-batch-store.ts": 15, @@ -201,7 +203,8 @@ "server/streaming/run-target-registry.ts": 1, "server/transport.ts": 77, "server/version-disposition.ts": 6, - "server/web-push-notifications.ts": 11 + "server/web-push-notifications.ts": 11, + "server/source-approved-authorization.ts": 30 }, "total": 7444, "meta": { diff --git a/reference-implementation/scripts/quality-ratchet/mass-justifications.json b/reference-implementation/scripts/quality-ratchet/mass-justifications.json index 22907caef..7a919ea31 100644 --- a/reference-implementation/scripts/quality-ratchet/mass-justifications.json +++ b/reference-implementation/scripts/quality-ratchet/mass-justifications.json @@ -4,6 +4,111 @@ "date": "2026-08-09", "reason": "Boot reconciliation now projects its abandons onto run_history, closing a live acceptance blocker: on the UAT instance 7 of 8 run_history rows claiming 'running' were zombies up to two days old, every one of them ALREADY carrying a run.abandoned spine event. Root cause is that both abandon emitters INSERT into spine_events directly (they need the spine_run_abandoned_cause_unique named-constraint idempotency a raw INSERT gives them) while run_history is written ONLY from inside emitSpineEvent, so the event log terminalised and the durable projection did not. A stuck row is not cosmetic: getActiveRun reads it, so the connection is refused every new run with 409 active_run_exists and its coverage checkpoint never leaves not_staged/not_committed (the RECORDS_NO_PROOF class). The +1 mass is repairTerminalRunHistoryDriftSqlite's terminalStatusForEventType branch set, which maps each of the five canonical terminal event types onto the status stores/run-history-writer.ts's toTerminalStatus would have derived, so a repaired row is indistinguishable from a writer-finalized one. That mapping is the load-bearing part: collapsing it to a single 'failed' would misreport a completed or cancelled run's outcome. Repair is deliberately fact-based, not time-based -- it reads the durable terminal event rather than a heartbeat/lease staleness threshold, which would need a guessed window and can be wrong in both directions (killing a slow live run, or waiting out the window before freeing a provably dead one). server/heartbeat-lease.ts was considered and rejected as the wrong primitive twice over: it is presentation-only and answers device-collector liveness, not run terminality. records_emitted is deliberately never written on either path, so records validly committed before the controller died stay committed per the RI-owner ruling. Proven by test/boot-orphan-run-history-projection.test.ts (4 tests): orphan terminalised with a typed reason, committed records retained, drift against an already-terminal spine repaired -- all three fail on the unmodified base -- plus a counterweight proving a run owned by the CURRENT incarnation is left running, which passes before and after and pins the no-over-correction half." }, + "server/source-declaration-trust/live-retrieval.ts": { + "allowed_mass": 6, + "reason": "Discovery RI (2026-08-11): this is the production send-time DNS-rebinding boundary. It must reject an invalid address set, create a fresh pinned dispatcher per hop, bound the streamed body, and close the dispatcher on fetch failure, EOF, cancellation, and stream error. Those branches are the transport ownership contract proven by live TLS, redirect, late-response, and byte-limit regressions.", + "date": "2026-08-11" + }, + "server/source-declaration-trust/retrieval.ts": { + "allowed_mass": 34, + "reason": "Discovery RI (2026-08-11): bounded declaration retrieval must independently distinguish URL policy, fresh per-hop DNS/address approval, manual redirect limits, absolute deadline, streamed byte cap, late-response cleanup, schema validation, and source identity. These are the fail-closed trust predicates; collapsing them would conflate security outcomes or leave an owned response transport open. Deterministic retrieval and real pinned-socket regressions cover the paths.", + "date": "2026-08-11" + }, + "server/source-declaration-trust/revision-store.ts": { + "allowed_mass": 4, + "reason": "Discovery RI (2026-08-11): immutable accepted-revision storage must canonicalize parsed content, distinguish first acceptance from identical replay, and reject equivocation for the same authority/source/opaque-version key on both SQLite and PostgreSQL. The small branch set is the durable trust invariant proven by backend parity tests.", + "date": "2026-08-11" + }, + "server/metadata.ts": { + "allowed_mass": 40, + "reason": "Discovery RI (2026-08-11): provider-native declaration-pointer emission must validate the exact protected-resource binding before it advertises the pointer. The added conditional keeps this native-only trust assertion at the metadata wire boundary; moving it to a generic helper or caller would either permit invalid metadata emission or split the request resource from the declaration-pointer contract. Focused metadata route coverage proves both native emission and hosted-MCP omission.", + "date": "2026-08-11" + }, + "server/routes/root-and-discovery.ts": { + "allowed_mass": 29, + "reason": "Discovery RI (2026-08-11): the protected-resource route must supply the configured declaration pointer only for provider-native metadata and omit it from the separate hosted-MCP resource. The two explicit route branches preserve resource-scoped advertisement rather than broadening the pointer to every metadata document. Focused native-route coverage proves the distinction.", + "date": "2026-08-11" + }, + "server/routes/as-agent-connect.ts": { + "allowed_mass": 109, + "reason": "Agent-connect durable handoff (2026-08-12): replacing the process-local polling Map with restart-safe SQLite/PostgreSQL rows adds explicit backend branches for hashed polling-code lookup, approval/denial completion, delivered-response recovery, expiry cleanup, and active-token revalidation before returning a retained bearer. The P1 closure adds the remaining fail-closed states: recovery when consent approval commits before attempt completion, expiry enforcement before any approved delivery or replay, exact bearer revocation when an approved attempt expires before delivery, retained expired tombstones so cleanup cannot delete an unreconciled row that a later approval will complete, and approval-completion revocation when it encounters an expired tombstone. The final CAS closure adds the ordered cleanup/complete race states: cleanup tombstones pending/expired rows before final durable recovery/revocation, never overwrites approved rows, completion revokes its just-committed token when an expired tombstone wins, and SQLite expired cleanup pages by integer rowid so batches over 1000 cannot bypass reconciliation. Tombstone collection now has independent durable predicates for pending/approving/approved consent retention, a sibling-aware token-revoke mutation, registration's SQLite guarded insert/PostgreSQL row claim, and a separate bounded historic-row deletion lane; each prevents a distinct token authority race or a non-progressing cleanup page and cannot be collapsed without restoring check-then-act behavior. The denial durability closure adds canonical approval_id-to-request_uri propagation, polling-time denied/expired consent reconciliation after a post-decision completion failure, and SQLite/PostgreSQL restart evidence; these branches keep denial terminal state and agent polling state convergent without issuing a bearer. These branches are the credential handoff contract; extracting them into shallow helpers would hide backend transaction predicates without reducing the state space. Proven by agent-cli SQLite approval/retry/cleanup, restart, crash-seam, cleanup/approval interleaving, approved-expiry/revocation, second-registration/concurrent-polling, 1001-row historic/batch and GC regressions, denial approval_id/restart/failure/expiry journeys, and live-PostgreSQL restart plus cleanup/approval, CAS interleaving, denial recovery, approved-expiry/revocation, GC, sibling delivery, and registration-race proof.", + "date": "2026-08-12" + }, + "server/routes/run-interaction.ts": { + "allowed_mass": 29, + "reason": "Direct-CDP terminal-barrier closure (2026-08-05): run-final cleanup must invoke the presentation terminal barrier for every run, including direct-CDP runs without a managed n.eko lease, before clearing the run nonce. The added hook is the single lifecycle seam that prevents a connector finalization path from skipping target purge; extracting it would obscure the required ordering.", + "date": "2026-08-05" + }, + "server/streaming/playground.ts": { + "allowed_mass": 26, + "reason": "Built-Core direct-CDP oracle (2026-08-05): the dev-only Patchright fixture must launch a real browser, pin one selected Page while opening a decoy, emit browser-surface assistance, support registration-disabled mutation, and terminalize cached run/interaction state. These branches are the bounded discriminator required to prove exact-page frame/input and stale fencing; collapsing them would remove an oracle state or conflate fixture cache policy with run identity.", + "date": "2026-08-05" + }, + "server/streaming/routes.ts": { + "allowed_mass": 201, + "reason": "Core direct-CDP lifecycle closure (2026-08-05): companion and controller authority must be keyed by runId, interactionId, and browserSessionId; direct-target readiness must fail closed before mint; terminalization must invalidate, stop, and force-unregister; and run cleanup must retire every lifecycle. These are independent security and cleanup predicates proven by route and built-Core tests, not incidental branching suitable for a shallow extraction.", + "date": "2026-08-05" + }, + "server/streaming/cdp-adapter.ts": { + "allowed_mass": 19, + "reason": "Direct-CDP Remote Surface 1.5.1 migration (2026-08-05): the adapter must preserve exact-page target discovery, adopted-page URL/title events, opener-gated popup classification, and blank-child deferral while bridging the installed assembled server backend's lifecycle, frame, keyboard-focus, input, viewport, and clipboard events into PDPP's existing wire. Those ordered predicates and the single lifecycle bridge are load-bearing authority/security boundaries; extracting them would either obscure event-state transitions or split the adapter from the one backend owner. Proven by the focused adapter, Remote Surface session, route authorization, and parity regressions.", + "date": "2026-08-05" + }, + "server/streaming/protocol-wire.ts": { + "allowed_mass": 30, + "reason": "Direct-CDP Remote Surface 1.5.1 migration (2026-08-05): the existing PDPP wire must carry the installed backend's keyboard_focus event without changing the SSE protocol's explicit event envelope or weakening its fallback payload handling. The added discriminant and projection are the single compatibility seam for server-side focus detection; removing them would drop the event or duplicate a second focus channel. Proven by the focused adapter, session, route, and parity regressions.", + "date": "2026-08-05" + }, + "server/streaming/run-target-registry.ts": { + "allowed_mass": 1, + "reason": "Run-final direct-CDP target purge (2026-08-05): clearNonce now deletes every target record owned by the terminal run as the crash/timeout safety net before nonce state is cleared. The single added branch is the load-bearing all-target cleanup predicate; TTL remains only defensive eviction.", + "date": "2026-08-05" + }, + "server/connector-maintenance-sweep.ts": { + "allowed_mass": 18, + "reason": "P0/P1/P2 fleet evidence convergence correction (2026-07-30): periodic and startup bounded evidence maintenance must retain a keyset cursor through a durable cross-backend store, acquire a generation-and-token fenced lease before the full read/sweep/write unit, reject malformed/non-resumable results without overwriting the prior cursor, reject a malformed completed result that carries a cursor, retain progress on failure, and discard a completion when its lease was superseded. An incomplete NULL result is valid only when the leased current cursor is also NULL, because the bounded sweep uses the cursor-before-page to retry a heavy first-page fold; after a non-null cursor it remains fail-closed. Startup must likewise retry the valid first-page NULL only through its existing round cap. These independent scheduling-safety branches sit at the orchestration boundary; extracting them would conceal ownership/error ordering or split a small module into shallow helpers. SQLite/real-PostgreSQL restart, cursor-loss, heavy-first-page, overlap, and stale-writer regressions prove the paths.", + "date": "2026-07-30" + }, + "server/ref-control.ts": { + "allowed_mass": 221, + "reason": "PR114 operator approval projection (2026-08-12): the existing approvals queue now distinguishes single consent, which supports the one-click reviewed-revision action, from batch consent, which must route to the hosted per-source review ceremony. The added discriminator prevents the operator surface from implicitly approving every batch source and is proven by projection and console guard tests. This extends the existing bounded approvals projection rather than adding another read model.", + "date": "2026-08-12" + }, + "server/routes/as-consent.ts": { + "allowed_mass": 87, + "reason": "PR114 reviewed-consent ceremony (2026-08-12): the route must keep review and issuance as separate HTTP transitions, map approval_id to a canonical server-side request URI, distinguish single and batch review inputs, reject mutable facts on final approval, and render the exact persisted artifact for resumed HTML. These branches are the public security boundary for explicit human consent; collapsing them would permit post-review mutation or bypass per-source batch confirmation. PR89 consent-exchange hardening applies credential no-store headers to JSON and HTML handoff responses and accepts an optional out-of-band recovery proof at the exchange boundary without exposing that proof in browser HTML. The denial handoff adapter now consumes the canonical request URI returned by the decision operation, so approval_id denial cannot leave an agent attempt pending; the one guard preserves the typed action boundary. Exact-artifact HTML/JSON, stale-revision, console batch, concurrent final-approval, cache-header, denial-reconciliation, and proof-bound replay regressions cover the states.", + "date": "2026-08-12" + }, + "server/routes/ref-connectors.ts": { + "allowed_mass": 33, + "reason": "Fable ruling terminal-read-architecture-fable-0730.md §8 (2026-07-30, identity_inventory) + design doc add-source-perf-design-agy-0730.md R4/R5 (2026-07-30, retained_count_summary + connector_id SET scope). The second increment adds a third accepted profile value (validated in the same one-place guard) and its matching branch/overload fork in sendConnectionScopedConnectorSummary's listConnectorSummaries closure, so the connection-scoped `?connection=` form can also serve retained_count_summary. The added branches are the profile parse/validate guard's extra arm and the closure's third call-site fork; both are the minimum surface for the route to expose a second additional named profile without a second route.", + "date": "2026-07-30" + }, + "server/stores/connector-maintenance-cursor-store.ts": { + "allowed_mass": 5, + "reason": "P1 PostgreSQL-replica maintenance fence (2026-07-30): the singleton cursor must atomically acquire only an unleased/expired row, issue a monotonically increasing generation plus opaque lease token, and condition both commit and failure release on both values. The SQLite BEGIN IMMEDIATE and PostgreSQL conditional UPDATE forms intentionally expose their backend predicates rather than hiding them behind a shallow SQL abstraction. A real PostgreSQL dual-runner regression and SQLite parity prove that an expired stale incomplete owner cannot replace a later complete NULL cursor.", + "date": "2026-07-30" + }, + "server/scheduler-manager-factory.ts": { + "allowed_mass": 76, + "reason": "TypeScript migration (2026-07-27): strict contracts replace implicit JavaScript surfaces for scheduler dependencies, and the original sequential refresh order is preserved with an explicit promise reduction rather than parallelizing manifest resolution and owner-token issuance. The four-point mass increase is the minimum control-flow cost of retaining that ordering while satisfying the no-await-in-loop rule; removing it would change refresh concurrency and behavior.", + "date": "2026-07-27" + }, + "server/connector-key.ts": { + "allowed_mass": 4, + "reason": "Lane C3c TypeScript migration (2026-07-26): canonicalConnectorKey's legacy-alias branch indexed LEGACY_LOCAL_ALIASES[trimmed] with an unchecked string in the original .js; under strict-plus typing that requires trimmed to be narrowed to the object's literal key union first. Added a 4-line isLegacyLocalAliasKey(value): value is LegacyLocalAlias type guard (baseline 2 -> current 4) rather than an unsafe index signature or an `as` assertion on the lookup. No behavior change: the guard's body is the same LEGACY_LOCAL_ALIAS_SET.has(value) check the pre-migration code already performed one line earlier before this branch was ever reached.", + "date": "2026-07-26" + }, + "server/local-transformer-child.ts": { + "allowed_mass": 1, + "reason": "Lane C3c TypeScript migration (2026-07-26): pump()'s `const job = queue.shift()` returns TransformerJob | undefined under noUncheckedIndexedAccess even though `queue.length > 0` in the enclosing while-condition already guarantees an element is present at this single-threaded call site. Added `if (!job) { break; }` (baseline 0 -> current 1) as the honest narrowing guard rather than a forbidden non-null assertion; the branch is unreachable in practice (queue is only ever read/written synchronously within this one module) but the type system cannot prove that from the API shape of Array.prototype.shift alone.", + "date": "2026-07-26" + }, + "server/stores/connector-instance-store.js": { + "allowed_mass": 28, + "reason": "D8 (fix-enroll-connector-instance-pk-collision): a live counterexample proved connector_instances.upsert's INSERT ... ON CONFLICT(owner, connector, source_kind, source_binding_key) is not resilient to a PRIMARY KEY collision against a legacy row for the SAME logical binding whose source_binding_key was computed under an OLDER, larger sourceBinding shape (embedding per-enrollment device_id/source_instance_id) that predates deviceExporterSourceBindingIdentity's stable {kind, local_binding_name}-only shape. That legacy row's id predates makeConnectorInstanceId and coincides with what today's formula computes for this same binding under its current key. Reaching the INSERT already proves the named ON CONFLICT target did not match this binding under its CURRENT key, so a 23505 here means either (a) the colliding row is this same binding under a legacy key (a migration, not a collision), or (b) it is a genuinely unrelated row (must fail closed). Both backends (Postgres try/catch; SQLite equivalent catching SQLITE_CONSTRAINT_PRIMARYKEY) now look up the colliding row and migrate it in place -- UPDATE its source_binding_key/source_binding_json to the current shape and return its SAME connector_instance_id -- ONLY when isSameLogicalBindingUnderLegacyKey proves it (same owner/connector/source_kind, and the local_binding_name embedded in its OWN stored source_binding_json matches); any other collision re-throws the raw 23505 unmodified. Proven load-bearing by a mutation test: reverting the migration logic makes the Postgres D8 regression (device-enroll-postgres-admission-decoupling.test.js) fail deterministically 3/3 runs with the exact live 503 enrollment_identity_conflict symptom; a SQLite unit test (connector-instance-store.test.js) additionally proves the fail-closed path for an unrelated colliding row.", + "date": "2026-07-25" + }, "lib/postgres-spine.ts": { "allowed_mass": 132, "date": "2026-07-20", @@ -450,5 +555,10 @@ "allowed_mass": 8, "date": "2026-08-11", "reason": "New leaf module (2026-08-11, direct-review residual close): extracted resolveNonNegativeMsOrInfinity out of runtime/scheduler.ts's resolveDispatchLivenessCeilingMs, which had accumulated a validation asymmetry (its explicit-value branch accepted any finite number including negative, while its env branch rejected negatives) and a second bug (Number('') coerces to 0, so an empty/whitespace PDPP_DISPATCH_LIVENESS_CEILING_MS silently disabled the dispatch-liveness deadline instead of falling back to the safe default). Both are fixed once, in one place, shared by both branches: reject negative/non-finite in either form, and treat empty/whitespace-only env as UNSET rather than as 0. This is genuine production reuse: resolveMaxRunWallClockMs in scheduler.ts was migrated onto this exact shared resolver (below), not left as a separate hand-rolled duplicate -- a direct diff review caught that an earlier revision of this change claimed 'real reuse' as its justification while resolveMaxRunWallClockMs still had its own hand-rolled, unmigrated implementation, which would have made this a misleading one-caller abstraction. The migration is a DELIBERATE, documented behavior change for two edge cases resolveMaxRunWallClockMs previously got wrong (matching the exact bugs dispatchLivenessCeilingMs had before this change): an explicit negative maxRunWallClockMs is now rejected (previously silently accepted -- only its env form rejected negatives), and an empty/whitespace PDPP_MAX_RUN_WALL_CLOCK_MS now falls to the 4-hour default instead of coercing to 0 (Number('') coerces to 0, silently disabling the connector-attempt watchdog). No existing test exercised either pre-migration edge case (grepped every maxRunWallClockMs test usage: all pass real config values -- 20, 5000, Infinity -- never a negative value or a blank env var), so this migration has zero observed regression surface. SchedulerOptions' own maxRunWallClockMs JSDoc was updated to document the 0/blank-env decision explicitly. A follow-up review rejected an initial __resolveDispatchLivenessCeilingMsForTests test-only export from scheduler.ts once this extraction made the resolver itself a legitimately unit-testable production module. Proven by scheduler-config.test.ts (17 tests as of 2026-08-11; count re-verified by a live node --test run, 17/17 pass): empty/whitespace env resolves to the real default and NOT to 0 (mutation-killed: reverting the trim guard reproduces 0 !== 1800000); a genuine explicit \"0\" is distinct from empty/unset and means disabled; negative/non-numeric values are rejected on both the explicit-value and env forms (mutation-killed: reverting either check reproduces a missing-exception failure); Infinity and a valid positive number resolve exactly; and an explicit value takes precedence over env. scheduler-dispatch-liveness-deadline.test.ts additionally proves the real wiring end to end through createScheduler with real PDPP_DISPATCH_LIVENESS_CEILING_MS env mutation and restoration." + }, + "server/source-approved-authorization.ts": { + "allowed_mass": 30, + "reason": "PR89 authorization hardening (2026-08-13): approved Source authorization validation must fail closed over retained declaration identity, stream membership, instance_ids, fields, time constraints, resources, unknown members, and widening. These checks are the authority boundary that prevents post-review authorization expansion before code delivery or token redemption; splitting them would hide the ordered validation contract rather than reduce it. PR89 seam receipts and authorization boundary tests cover these branches.", + "date": "2026-08-13" } } diff --git a/reference-implementation/scripts/run-pr89-seam.ts b/reference-implementation/scripts/run-pr89-seam.ts new file mode 100644 index 000000000..12e09259d --- /dev/null +++ b/reference-implementation/scripts/run-pr89-seam.ts @@ -0,0 +1,268 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + ARTIFACT_ROOT, + EVIDENCE_ROOT, + fileSetDigest, + generateReceipt, + parseCaseOutput, + RECEIPT_PATH, + REPOSITORY_ROOT, + verifyReceipt, +} from "./check-pr89-seam-receipt.ts"; +import { + CASE_DEFINITIONS, + CASE_EVIDENCE_SCHEMA, + CASE_EXECUTION_ORDER, + CASE_OUTPUT_SCHEMA, + type CaseEvidence, + type CaseId, + type CaseOutput, + canonicalJson, + digest, + exactSortedValues, + type Json, +} from "./pr89-seam-evidence-contract.ts"; + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const CASE_OUTPUT_ROOT = resolve(ARTIFACT_ROOT, "case-outputs"); +const REPORTER_PATH = resolve(REPOSITORY_ROOT, "scripts/test-accounting/node-reporter.ts"); +const EVENT_PREFIX = "PDPP_TEST_ACCOUNTING_EVENT "; +const MAX_FAILURE_OUTPUT_LENGTH = 8000; + +interface ReporterEvent { + details?: { + name?: string; + skip?: boolean | string; + type?: string; + }; + type?: string; +} + +interface ChildResult { + exitCode: number; + output: string; + signal: NodeJS.Signals | null; +} + +function fail(message: string): never { + throw new Error(`PR89 seam: ${message}`); +} + +function parseBackend(argv: string[]): "postgresql" { + const args = argv[0] === "--" ? argv.slice(1) : argv; + if (args.length !== 2 || args[0] !== "--backend" || args[1] !== "postgresql") { + fail("use exactly --backend postgresql"); + } + return "postgresql"; +} + +function requirePostgresUrl(value: string | undefined): string { + if (!value) { + fail("PDPP_TEST_POSTGRES_URL is required"); + } + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return fail("PDPP_TEST_POSTGRES_URL must be a valid URL"); + } + if (!["postgres:", "postgresql:"].includes(parsed.protocol)) { + fail("PDPP_TEST_POSTGRES_URL must use PostgreSQL"); + } + if ( + !["127.0.0.1", "localhost"].includes(parsed.hostname) || + parsed.search || + parsed.hash || + parsed.pathname === "/" + ) { + fail("PDPP_TEST_POSTGRES_URL must name a query-free loopback test database"); + } + return value; +} + +function stableCommand(caseId: CaseId): string[] { + return [ + "node", + "--test", + "--import", + "tsx", + "--test-reporter", + "scripts/test-accounting/node-reporter.ts", + CASE_DEFINITIONS[caseId].testFile, + ]; +} + +function runtimeCommand(caseId: CaseId): string[] { + return [ + "--test", + "--import", + "tsx", + "--test-reporter", + REPORTER_PATH, + resolve(REPOSITORY_ROOT, CASE_DEFINITIONS[caseId].testFile), + ]; +} + +function runChild(caseId: CaseId, postgresUrl: string, caseOutputPath: string): Promise<ChildResult> { + const definition = CASE_DEFINITIONS[caseId]; + const env: NodeJS.ProcessEnv = { ...process.env, PDPP_TEST_POSTGRES_URL: postgresUrl }; + if (definition.outputRequired) { + env.PDPP_PR89_CASE_OUTPUT_PATH = caseOutputPath; + } else { + env.PDPP_PR89_CASE_OUTPUT_PATH = undefined; + } + return new Promise((resolveResult, reject) => { + const child = spawn(process.execPath, runtimeCommand(caseId), { + cwd: REPOSITORY_ROOT, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.on("error", reject); + child.on("exit", (code, signal) => { + resolveResult({ exitCode: code ?? 1, output, signal }); + }); + }); +} + +function terminalEvents(caseId: CaseId, result: ChildResult): Array<{ name: string; status: "pass" }> { + const events: ReporterEvent[] = result.output + .split("\n") + .filter((line) => line.startsWith(EVENT_PREFIX)) + .map((line) => { + try { + return JSON.parse(line.slice(EVENT_PREFIX.length)) as ReporterEvent; + } catch { + return fail(`${caseId} reporter emitted malformed structured JSON`); + } + }); + if (result.exitCode !== 0 || result.signal) { + const tail = result.output.slice(-MAX_FAILURE_OUTPUT_LENGTH); + fail(`${caseId} execution failed with exit ${result.exitCode}, signal ${result.signal ?? "none"}\n${tail}`); + } + const tests = events.filter((event) => event.details?.type === "test"); + if (tests.length === 0) { + fail(`${caseId} emitted no structured test events`); + } + for (const event of tests) { + if (event.type !== "test:pass" || event.details?.skip !== undefined) { + fail(`${caseId} has a failed or skipped test: ${event.details?.name ?? "unnamed"}`); + } + } + const names = tests.map((event) => event.details?.name).filter((name): name is string => Boolean(name)); + if (names.length !== tests.length || new Set(names).size !== names.length) { + fail(`${caseId} test names must be present and unique`); + } + for (const requiredName of CASE_DEFINITIONS[caseId].requiredTestNames) { + if (!names.includes(requiredName)) { + fail(`${caseId} did not execute required test: ${requiredName}`); + } + } + return exactSortedValues(names).map((name) => ({ name, status: "pass" })); +} + +async function readCaseOutput(caseId: CaseId, path: string): Promise<CaseOutput> { + const definition = CASE_DEFINITIONS[caseId]; + if (!definition.outputRequired) { + return { + case_id: caseId, + observations: exactSortedValues(definition.observations), + oracle_code: definition.oracleCode, + response_envelopes: [], + schema: CASE_OUTPUT_SCHEMA, + }; + } + if (!existsSync(path)) { + fail(`${caseId} did not write PDPP_PR89_CASE_OUTPUT_PATH`); + } + const raw = (await readFile(path, "utf8")).trim(); + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return fail(`${caseId} output is not valid JSON`); + } + const parsed = parseCaseOutput(value, caseId, definition); + if (raw !== canonicalJson(parsed as unknown as Json)) { + fail(`${caseId} output must use canonical lexicographic JSON key ordering`); + } + return parsed; +} + +async function executeCase(caseId: CaseId, postgresUrl: string): Promise<void> { + const definition = CASE_DEFINITIONS[caseId]; + const caseOutputPath = resolve(CASE_OUTPUT_ROOT, `${caseId}.json`); + await rm(caseOutputPath, { force: true }); + const result = await runChild(caseId, postgresUrl, caseOutputPath); + const events = terminalEvents(caseId, result); + const output = await readCaseOutput(caseId, caseOutputPath); + const evidence: CaseEvidence = { + backend: "postgresql", + case_id: caseId, + case_output: output, + case_output_digest: digest(canonicalJson(output as unknown as Json)), + command: stableCommand(caseId), + fixtures_digest: await fileSetDigest(REPOSITORY_ROOT, definition.fixturePaths), + implementation_inputs_digest: await fileSetDigest(REPOSITORY_ROOT, definition.implementationInputPaths), + oracle_code: definition.oracleCode, + schema: CASE_EVIDENCE_SCHEMA, + status: "pass", + terminal_events: events, + terminal_events_digest: digest(canonicalJson(events)), + test_file_digest: await fileSetDigest(REPOSITORY_ROOT, [definition.testFile]), + }; + await mkdir(EVIDENCE_ROOT, { recursive: true }); + await writeFile(resolve(EVIDENCE_ROOT, `${caseId}.json`), `${canonicalJson(evidence as unknown as Json)}\n`, "utf8"); + process.stdout.write(`${caseId}: ${events.length} structured tests passed\n`); +} + +function missingInputs(caseId: CaseId): string[] { + const definition = CASE_DEFINITIONS[caseId]; + return [definition.testFile, ...definition.fixturePaths, ...definition.implementationInputPaths].filter( + (path) => !existsSync(resolve(REPOSITORY_ROOT, path)) + ); +} + +export async function runSeam(argv = process.argv.slice(2)): Promise<void> { + parseBackend(argv); + const postgresUrl = requirePostgresUrl(process.env.PDPP_TEST_POSTGRES_URL); + await rm(EVIDENCE_ROOT, { force: true, recursive: true }); + await rm(CASE_OUTPUT_ROOT, { force: true, recursive: true }); + await rm(RECEIPT_PATH, { force: true }); + await mkdir(CASE_OUTPUT_ROOT, { recursive: true }); + const missing = new Map<CaseId, string[]>(); + for (const caseId of CASE_EXECUTION_ORDER) { + const absent = missingInputs(caseId); + if (absent.length > 0) { + missing.set(caseId, absent); + continue; + } + // biome-ignore lint/performance/noAwaitInLoops: Cases share one PostgreSQL database and must not overlap. + await executeCase(caseId, postgresUrl); + } + if (missing.size > 0) { + const details = [...missing.entries()].map(([caseId, paths]) => `${caseId}: ${paths.join(", ")}`).join("\n"); + fail(`receipt generation requires all seven executed cases; missing inputs:\n${details}`); + } + await generateReceipt(); + await verifyReceipt(); + process.stdout.write(`PR89 seam receipt written to ${RECEIPT_PATH}\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === SCRIPT_PATH) { + await runSeam(); +} diff --git a/reference-implementation/scripts/run-tests-watchdog.test.ts b/reference-implementation/scripts/run-tests-watchdog.test.ts new file mode 100644 index 000000000..a1fb17669 --- /dev/null +++ b/reference-implementation/scripts/run-tests-watchdog.test.ts @@ -0,0 +1,88 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import test from "node:test"; +import { startFileProcessWatchdog } from "./file-process-watchdog.ts"; + +test("reporter progress extends the idle budget but not the hard deadline", () => { + let now = 0; + let tick: (() => void) | undefined; + let killed = 0; + const timer = { unref: () => undefined } as unknown as ReturnType<typeof setInterval>; + + const watchdog = startFileProcessWatchdog({ + hardDeadlineMs: 300, + idleBudgetMs: 100, + kill: () => { + killed += 1; + }, + now: () => now, + schedule: (callback) => { + tick = callback; + return timer; + }, + }); + + now = 99; + tick?.(); + assert.equal(killed, 0); + watchdog.markProgress(); + + now = 190; + tick?.(); + assert.equal(killed, 0, "progress keeps a finite file inside the idle budget"); + watchdog.markProgress(); + + now = 289; + tick?.(); + assert.equal(killed, 0, "progress can extend total runtime beyond the idle budget"); + + now = 300; + tick?.(); + assert.equal(killed, 1, "the absolute deadline still bounds a progressing process"); + assert.equal(watchdog.timeoutReason(), "hard"); +}); + +test("the idle watchdog terminates a silent child", async () => { + const child = spawn(process.execPath, ["-e", "process.stdout.write('ready\\n'); setInterval(() => {}, 1000);"], { + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + await new Promise<void>((resolve, reject) => { + const guard = setTimeout(() => reject(new Error("silent child did not become ready")), 5000); + guard.unref?.(); + child.stdout?.once("data", () => { + clearTimeout(guard); + resolve(); + }); + child.once("error", reject); + }); + + const watchdog = startFileProcessWatchdog({ + hardDeadlineMs: 5000, + idleBudgetMs: 300, + kill: () => child.kill("SIGKILL"), + }); + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + const guard = setTimeout(() => reject(new Error("silent child survived its idle budget")), 8000); + guard.unref?.(); + child.once("close", (code, signal) => { + clearTimeout(guard); + resolve({ code, signal }); + }); + child.once("error", reject); + }); + + watchdog.clear(); + assert.equal(watchdog.timeoutReason(), "idle"); + assert.equal(child.killed, true); + assert.ok(result.signal === "SIGKILL" || result.code !== 0, "the silent child must not exit successfully"); + } finally { + if (!child.killed) { + child.kill("SIGKILL"); + } + } +}); diff --git a/reference-implementation/scripts/run-tests.ts b/reference-implementation/scripts/run-tests.ts index 1039a25c6..72129c3aa 100644 --- a/reference-implementation/scripts/run-tests.ts +++ b/reference-implementation/scripts/run-tests.ts @@ -24,6 +24,7 @@ import { isDedicatedPostgresTestDatabaseName, } from "../test/helpers/dedicated-postgres-test-url.ts"; import { deriveDedicatedPostgresDbNameForFile } from "./dedicated-postgres-db-name.ts"; +import { startFileProcessWatchdog } from "./file-process-watchdog.ts"; import type { ProcessEnvLike } from "./test-env.ts"; import { buildScrubbedTestEnv } from "./test-env.ts"; import { storageProfileEnvironment } from "./test-profile-env.ts"; @@ -62,10 +63,36 @@ const forwardedArgs = // fully green file. Bounded termination for a genuinely hung file (a leaked // handle after every test has already finished) is instead enforced by this // runner's own watchdog in runNodeTest(), which lets a normal run exit on -// its own (draining reporter output completely) and only signals a child -// that fails to exit within PER_FILE_TIMEOUT_MS. +// its own (draining reporter output completely) and signals a child that +// either stops producing output or exceeds a separate absolute deadline. const effectiveArgs = forwardedArgs; -const PER_FILE_TIMEOUT_MS = Number.parseInt(process.env.PDPP_TEST_FILE_TIMEOUT_MS || "", 10) || 120_000; +const DEFAULT_PER_FILE_IDLE_TIMEOUT_MS = 120_000; +// cli.test.ts took about 343s in isolation, and collection-profile.test.ts +// exceeded the former 480s wall-clock limit while still producing reporter +// events. Fifteen minutes leaves measured headroom and remains below one +// third of the 45-minute CI job limit. +const DEFAULT_PER_FILE_HARD_TIMEOUT_MS = 900_000; + +function readPositiveTimeout(name: string, fallbackMs: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === "") { + return fallbackMs; + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive safe integer number of milliseconds`); + } + return value; +} + +const PER_FILE_IDLE_TIMEOUT_MS = readPositiveTimeout("PDPP_TEST_FILE_TIMEOUT_MS", DEFAULT_PER_FILE_IDLE_TIMEOUT_MS); +const PER_FILE_HARD_TIMEOUT_MS = readPositiveTimeout( + "PDPP_TEST_FILE_HARD_TIMEOUT_MS", + DEFAULT_PER_FILE_HARD_TIMEOUT_MS +); +if (PER_FILE_HARD_TIMEOUT_MS < PER_FILE_IDLE_TIMEOUT_MS) { + throw new Error("PDPP_TEST_FILE_HARD_TIMEOUT_MS must be greater than or equal to PDPP_TEST_FILE_TIMEOUT_MS"); +} if (!effectiveArgs.some((arg) => arg === "--test-reporter" || arg.startsWith("--test-reporter="))) { effectiveArgs.push( `--test-reporter=${fileURLToPath(new URL("../../scripts/test-accounting/node-reporter.ts", import.meta.url))}` @@ -294,31 +321,26 @@ async function runNodeTest(filePath: string, extraArgs: string[]): Promise<NodeT stdio: ["ignore", "pipe", "pipe"], }); let output = ""; - let timedOut = false; - - // Watchdog: a normal run drains its reporter stream and exits on its - // own well within this window, so the timer never fires and never - // touches the child. It only acts on a file that is genuinely stuck - // (e.g. a leaked handle keeping the event loop alive after every test - // already finished) — the case --test-force-exit used to (mis)handle by - // truncating the reporter's event stream for every run, not just hung - // ones. SIGKILL (not SIGTERM) because a hang implies the process isn't - // responding to its own event loop, so a graceful signal isn't reliable. - const watchdog = setTimeout(() => { - timedOut = true; - child.kill("SIGKILL"); - }, PER_FILE_TIMEOUT_MS); - watchdog.unref?.(); + + // Reporter output proves that a long file is still making progress. The + // hard deadline separately bounds a chatty process that never exits. + const watchdog = startFileProcessWatchdog({ + hardDeadlineMs: PER_FILE_HARD_TIMEOUT_MS, + idleBudgetMs: PER_FILE_IDLE_TIMEOUT_MS, + kill: () => child.kill("SIGKILL"), + }); child.stdout?.on("data", (chunk: Buffer) => { + watchdog.markProgress(); output += chunk.toString(); }); child.stderr?.on("data", (chunk: Buffer) => { + watchdog.markProgress(); output += chunk.toString(); }); child.on("error", (err) => { - clearTimeout(watchdog); + watchdog.clear(); if (allocation) { allocation.release().finally(() => reject(err)); } else { @@ -326,10 +348,23 @@ async function runNodeTest(filePath: string, extraArgs: string[]): Promise<NodeT } }); child.on("exit", (code, signal) => { - clearTimeout(watchdog); + watchdog.clear(); const finish = () => { - if (timedOut) { - reject(new Error(`Test process for ${filePath} timed out after ${PER_FILE_TIMEOUT_MS}ms and was killed`)); + const timeoutReason = watchdog.timeoutReason(); + if (timeoutReason === "idle") { + reject( + new Error( + `Test process for ${filePath} timed out after ${PER_FILE_IDLE_TIMEOUT_MS}ms without child output and was killed` + ) + ); + return; + } + if (timeoutReason === "hard") { + reject( + new Error( + `Test process for ${filePath} exceeded the ${PER_FILE_HARD_TIMEOUT_MS}ms hard deadline and was killed` + ) + ); return; } if (signal) { diff --git a/reference-implementation/server/auth-middleware.ts b/reference-implementation/server/auth-middleware.ts index 0de206633..bb8b90a29 100644 --- a/reference-implementation/server/auth-middleware.ts +++ b/reference-implementation/server/auth-middleware.ts @@ -205,6 +205,10 @@ async function respondToInactiveToken(req: RequestLike, res: ResponseLike, info: if (info.inactive_reason === "grant_invalid") { return pdppError(res, 403, "grant_invalid", "Grant is malformed or no longer valid"); } + if (info.inactive_reason === "authorization_state.unsupported_legacy_shape") { + setProtectedResourceMetadataChallenge(res); + return pdppError(res, 401, info.inactive_reason, "Fresh consent is required"); + } setProtectedResourceMetadataChallenge(res); return pdppError(res, 401, "authentication_error", "Invalid or expired token"); } diff --git a/reference-implementation/server/auth.ts b/reference-implementation/server/auth.ts index 48d87901e..fba8612c3 100644 --- a/reference-implementation/server/auth.ts +++ b/reference-implementation/server/auth.ts @@ -11,9 +11,12 @@ * - Implements RFC 7662-style introspection with PDPP extensions */ import { randomBytes } from "node:crypto"; +import { createRequire } from "node:module"; import { BATCH_CONSENT_STAGED_ENTRY_SOFT_CAP, BATCH_CONSENT_STAGED_ENTRY_WARNING_THRESHOLD, + ResolvedGrantSchema, + validateResponse, } from "@pdpp/reference-contract"; import { allowUnboundedReadAcknowledged, @@ -24,7 +27,9 @@ import { type RegisteredQuery, referenceQueries, transaction, + writeTransaction, } from "../lib/db.ts"; +import { postgresEmitSpineEventInTransaction } from "../lib/postgres-spine.ts"; import { createTraceContext, emitSpineEvent as emitRawSpineEvent, type SpineEventInput } from "../lib/spine.ts"; import type { CimdFetchDependencies, CimdTransportFailureEvent } from "./cimd.ts"; import { listActiveBindingsForGrant, projectBindingForWire } from "./connection-identity.ts"; @@ -34,8 +39,16 @@ import { resolveManifestSensitivity, validateConnectorManifest, } from "./connector-manifest-validation.ts"; +import { + projectResolvedCoreGrantStreams as coreProjectResolvedGrantStreams, + coreSchemaRequiredFields, + createRetainedCoreConsentSnapshot, + materializeCoreResolvedGrant, + readRetainedCoreConsentSnapshot, + resolveCoreEligibleInstanceIds, + validateCoreSelectionRequest, +} from "./core-source-authorization.ts"; import { getDb, runWithSqliteBusyRetry } from "./db.ts"; -import { assertManifestReadAuthority } from "./manifest-read-authority.ts"; import { base64UrlSha256, generateOAuthRefreshToken, @@ -45,11 +58,26 @@ import { SUPPORTED_AUTHORIZATION_CODE_CHALLENGE_METHODS, } from "./oauth-substrate/primitives.ts"; import { isPostgresStorageBackend, postgresQuery, withPostgresTransaction } from "./postgres-storage.ts"; +import { buildGrantedAuthorizationDetail } from "./source-approved-authorization.ts"; +import { snapshotSourceDeclaration } from "./source-declaration.ts"; +import { snapshotContentAddressedSourceDeclarationFromLegacyConnectorManifest } from "./source-declaration-legacy-collection.ts"; +import { + type AcceptedSourceDeclarationRevisionStore, + acceptedRevisionEvidenceReference, +} from "./source-declaration-trust/revision-store.ts"; +import { getAcceptedProviderNativeDeclarationRevision } from "./source-declaration-trust/service.ts"; +import { + createPostgresConnectorInstanceStore, + createSqliteConnectorInstanceStore, + makeDefaultAccountConnectorInstanceId, + resolveOwnerConnectorInstanceNamespace, +} from "./stores/connector-instance-store.ts"; // ─── Domain types ───────────────────────────────────────────────────────────── interface AuthError extends Error { code?: string; + fresh_authorization_required?: boolean; param?: string; request_id?: string; result?: unknown; @@ -64,10 +92,17 @@ interface TraceContext { } interface InitiateGrantOptions { + acceptedProviderNativeRevision?: AcceptedProviderNativeRevisionForConsent; + /** Internal handle produced by provider-native onboarding. It is evidence, never a grant right. */ + acceptedRevisionReference?: string; + acceptedRevisionStore?: AcceptedSourceDeclarationRevisionStore; baseUrl?: string; cimdFetchDependencies?: CimdFetchDependencies; issuerBase?: string; + /** Explicit local/test operator provisioning. This is not verified provider-native discovery. */ nativeManifest?: DbRow | null; + /** Required when nativeManifest supplies storage only for an accepted provider-native revision. */ + nativeManifestMode?: "fulfillment_only" | "local_operator_provisioning"; onCimdTransportFailure?: (event: CimdTransportFailureEvent) => void; scenarioId?: string; } @@ -83,20 +118,19 @@ interface StorageBinding { } interface StreamSelection extends Record<string, unknown> { - client_claims?: unknown; - connection_id?: string; fields?: string[]; + instance_ids?: string[]; name: string; necessity?: string; - resources?: unknown[]; + resources?: string[]; + time_constraint?: { field: string; since?: string; until?: string }; time_range?: { since?: string; [key: string]: unknown }; view?: string; } interface RawStreamSelection { - client_claims?: unknown | undefined; - connection_id?: string | undefined; fields?: unknown[] | undefined; + instance_ids?: unknown[] | undefined; name: unknown; necessity?: unknown | undefined; resources?: unknown[] | undefined; @@ -106,10 +140,12 @@ interface RawStreamSelection { interface GrantSelection { access_mode: string; - purpose_code?: string | undefined; + client_claims?: unknown | undefined; + purpose_code: string; purpose_description?: string | undefined; retention?: unknown | undefined; - streams: RawStreamSelection[]; + selection_preset?: string | undefined; + streams?: RawStreamSelection[] | undefined; type: string; } @@ -126,6 +162,7 @@ interface PendingRequest { request_version: string; selection: GrantSelection; source_binding?: SourceBinding | null | undefined; + source_declaration_snapshot?: SourceDeclarationSnapshot | undefined; storage_binding?: StorageBinding | null | undefined; trace_context?: TraceContext | undefined; } @@ -134,6 +171,7 @@ interface BatchEntry { manifest_version?: string | undefined; selection: GrantSelection; source_binding?: SourceBinding | null | undefined; + source_declaration_snapshot?: SourceDeclarationSnapshot | undefined; storage_binding?: StorageBinding | null | undefined; } @@ -184,12 +222,17 @@ interface RegisteredClient { updated_at: string | null; } -interface ConsentExchangeEntry { - consumed: boolean; - expiresAt: number; - grant: Record<string, unknown>; - grantId: string; - token: string; +interface ConsentExchangeRow extends DbRow { + code_hash: string; + created_at: string; + expires_at: string; + grant_id: string | null; + package_id: string | null; + proof_hash: string | null; + redeemed_at: string | null; + token_expires_at: string | null; + token_id: string; + token_revoked: boolean | number; } interface GrantPackageNormalized { @@ -273,23 +316,51 @@ type SqliteBusyRetryOptions = NonNullable<Parameters<typeof runWithSqliteBusyRet interface GrantEnvelope extends DbRow { access_mode: string; client: { - client_display?: ClientDisplay; client_id: string; - registration_mode: string; }; expires_at: string | null; grant_id: string; issued_at: string; - manifest_version: string; - purpose_code: string | undefined; + purpose_code: string; purpose_description: string | undefined; retention: unknown; - source: SourceBinding | null; - streams: StreamSelection[]; + selection_preset?: string; + source: SourceBinding; + source_declaration: { version: string }; + streams: ResolvedGrantStream[]; subject: { id: string }; version: string; } +interface ResolvedGrantStream extends Record<string, unknown> { + fields: string[]; + instance_ids: string[]; + name: string; + resources?: string[]; + time_constraint?: { field: string; since?: string; until?: string }; +} + +interface SourceDeclarationSnapshot { + accepted_revision_reference?: string; + declaration: DbRow; + declaration_version: string; + publisher_attribution?: SourcePublisherAttribution; + resolved_streams: StreamSelection[]; + resource_authority?: SourceResourceAuthority; + snapshot_version: "reference.source-declaration-snapshot.v1"; + source: SourceBinding; + source_sensitivity: string; +} + +interface SourcePublisherAttribution { + id: string; + status: "unverified"; +} + +type SourceResourceAuthority = + | { authority_binding: string; status: "verified" } + | { status: "local_operator_provisioned" }; + interface RegisteredClientRow extends DbRow { client_id: string; client_secret: string | null; @@ -302,6 +373,7 @@ interface RegisteredClientRow extends DbRow { interface GrantPackageMemberRow extends DbRow { added_at: string; + grant_access_mode?: string; grant_id: string; grant_status: string; member_revoked_at?: string | null; @@ -318,8 +390,10 @@ interface GrantPackageListRow extends DbRow { created_at: string; member_count?: number | string; package_id: string; + scenario_id: string | null; status: string; subject_id: string; + trace_id: string | null; } interface GrantPackageCursor { @@ -328,6 +402,9 @@ interface GrantPackageCursor { } interface PendingConsentRow extends DbRow { + approval_review_digest?: string | null; + approval_review_json?: string | null; + approval_review_revision?: string | null; created_at: string; device_code: string; expires_at: string; @@ -360,13 +437,36 @@ interface OwnerDeviceAuthRow extends DbRow { user_code: string; } +export type OwnerDeviceApprovalFaultHook = (stage: "before_token_insert" | "after_token_insert") => void; + +export type AuthorizationDecisionFaultStage = "after_cas_before_event" | "after_event_before_commit"; +export type AuthorizationDecisionFaultHook = (stage: AuthorizationDecisionFaultStage) => void; + +interface OwnerDeviceApprovalInput { + clientId: string; + consentApprovedEvent: AuthSpineEventInput; + deviceCode: string; + expiresAt: string; + faultHook?: OwnerDeviceApprovalFaultHook | undefined; + pendingSnapshot: OwnerDeviceAuthRow; + subjectId: string; + tokenId: string; + tokenIssuedEvent: AuthSpineEventInput; +} + interface OAuthPendingCodeRow extends DbRow { client_id: string; + consumed_at: string | null; device_code: string; expires_at: string; + grant_id: string | null; + issued_at: string | null; + issued_code: string | null; + package_id: string | null; redirect_uri: string; state: string | null; status: string; + token_id: string | null; } interface OAuthIssuedCodeRow extends DbRow { @@ -386,25 +486,36 @@ interface OAuthIssuedCodeRow extends DbRow { interface RefreshTokenRow extends DbRow { client_id: string; expires_at: string | null; + family_id: string; + generation: number; grant_id: string | null; package_id: string | null; + parent_generation: number | null; revoked_at: string | null; status: string; subject_id: string; + superseded_at: string | null; } interface GrantIssuanceRow extends DbRow { access_mode: string; + client_id: string; consumed: boolean | number; + expires_at: string | null; + grant_id: string; grant_json: string; scenario_id: string | null; status: string; storage_binding_json: string | null; + subject_id: string; trace_id: string; } interface GrantRevocationRow extends DbRow { + access_mode: string; client_id: string; + expires_at: string | null; + grant_id: string; grant_json: string; scenario_id: string | null; storage_binding_json: string | null; @@ -423,6 +534,8 @@ interface TokenIntrospectionRow extends DbRow { package_scenario_id: string | null; package_status: string | null; package_trace_id: string | null; + refresh_family_active: boolean | number | null; + refresh_family_id: string | null; revoked: boolean | number; scenario_id: string | null; storage_binding_json: string | null; @@ -434,7 +547,7 @@ interface TokenIntrospectionRow extends DbRow { interface TokenIntrospectionResult extends Record<string, unknown> { active: boolean; client_id?: string | null; - exp?: number | null; + exp?: number; grant_id?: string | null; grant_package_id?: string | null; pdpp_token_kind?: string; @@ -466,7 +579,7 @@ interface PendingConsentStore { insert: (input: { deviceCode: string; userCode: string; - params: PendingRequest | StagedBatchRequest; + paramsJson: string; traceContext: TraceContext; createdAt: string; expiresAt: string; @@ -480,12 +593,18 @@ interface PendingConsentStore { aiTrainingConsented: boolean | null | undefined; approvedAt: string; }) => MaybePromise<StoreWriteResult>; - markDenied: (input: { deviceCode: string; deniedAt: string }) => MaybePromise<StoreWriteResult>; + markDeniedAtomically: (input: { + deviceCode: string; + deniedAt: string; + event: AuthSpineEventInput; + faultHook?: AuthorizationDecisionFaultHook; + }) => MaybePromise<StoreWriteResult>; markExpired: (input: { deviceCode: string }) => MaybePromise<StoreWriteResult>; updateLastPolled: (input: { deviceCode: string; polledAt: string }) => MaybePromise<StoreWriteResult>; } interface OwnerDeviceAuthStore { + approveAtomically: (input: OwnerDeviceApprovalInput) => MaybePromise<OwnerDeviceAuthRow>; getByApprovalId: (approvalId: string) => MaybePromise<OwnerDeviceAuthRow | null>; getByDeviceCode: (deviceCode: string) => MaybePromise<OwnerDeviceAuthRow | null>; getByUserCode: (userCode: string) => MaybePromise<OwnerDeviceAuthRow | null>; @@ -507,7 +626,12 @@ interface OwnerDeviceAuthStore { tokenId: string; approvedAt: string; }) => MaybePromise<StoreWriteResult>; - markDenied: (input: { deviceCode: string; deniedAt: string }) => MaybePromise<StoreWriteResult>; + markDeniedAtomically: (input: { + deviceCode: string; + deniedAt: string; + event: AuthSpineEventInput; + faultHook?: AuthorizationDecisionFaultHook; + }) => MaybePromise<StoreWriteResult>; markExpired: (input: { deviceCode: string }) => MaybePromise<StoreWriteResult>; updateLastPolled: (input: { deviceCode: string; polledAt: string }) => MaybePromise<StoreWriteResult>; } @@ -550,6 +674,7 @@ interface CimdStore { interface ConnectorCatalogStore { getManifestById: (connectorId: string) => MaybePromise<DbRow | null>; + listBySourceId: (sourceId: string) => MaybePromise<readonly DbRow[]>; listIds: () => MaybePromise<readonly DbRow[]>; upsert: (input: { connectorId: string; manifestJson: string }) => MaybePromise<StoreWriteResult>; } @@ -639,9 +764,11 @@ interface OAuthCodeStore { } interface RefreshTokenStore { - getByTokenHash: (refreshTokenHash: string) => MaybePromise<RefreshTokenRow | null>; insert: (input: { refreshTokenHash: string; + familyId: string; + generation: number; + parentGeneration: number | null; clientId: string; grantId: string; subjectId: string; @@ -650,13 +777,15 @@ interface RefreshTokenStore { }) => MaybePromise<StoreWriteResult>; insertForPackage: (input: { refreshTokenHash: string; + familyId: string; + generation: number; + parentGeneration: number | null; clientId: string; packageId: string; subjectId: string; createdAt: string; expiresAt: string | null; }) => MaybePromise<StoreWriteResult>; - markUsed: (input: { usedAt: string; refreshTokenHash: string }) => MaybePromise<StoreWriteResult>; } interface TokenStore { @@ -684,6 +813,24 @@ function expiresInIso(seconds: number): string { return new Date(Date.now() + seconds * 1000).toISOString(); } +const OAUTH_REFRESH_ACCESS_TOKEN_LIFETIME_SECONDS = 10 * 60; + +function refreshAccessTokenExpiresAt(issuedAt: string, refreshFamilyExpiresAt: string | null): string { + const issuedAtMs = Date.parse(issuedAt); + if (!Number.isFinite(issuedAtMs)) { + throw buildOAuthRefreshTokenError("invalid_grant", "Refresh token issuance time is invalid"); + } + const shortExpiryMs = issuedAtMs + OAUTH_REFRESH_ACCESS_TOKEN_LIFETIME_SECONDS * 1000; + if (!refreshFamilyExpiresAt) { + return new Date(shortExpiryMs).toISOString(); + } + const familyExpiryMs = Date.parse(refreshFamilyExpiresAt); + if (!Number.isFinite(familyExpiryMs)) { + throw buildOAuthRefreshTokenError("invalid_grant", "Refresh token family expiry is invalid"); + } + return new Date(Math.min(shortExpiryMs, familyExpiryMs)).toISOString(); +} + function isExpired(row: DbRow): boolean { return new Date(String(row.expires_at)).getTime() <= Date.now(); } @@ -734,19 +881,9 @@ const SUPPORTED_PENDING_REQUEST_FIELDS = new Set([ "parent_package_id", "scenario_id", ]); -const SUPPORTED_AUTHORIZATION_DETAIL_FIELDS = new Set([ - "access_mode", - "purpose_code", - "purpose_description", - "retention", - "source", - "streams", - "type", -]); const SUPPORTED_STREAM_SELECTION_FIELDS = new Set([ - "client_claims", - "connection_id", "fields", + "instance_ids", "name", "necessity", "resources", @@ -759,17 +896,19 @@ const SUPPORTED_NORMALIZED_PENDING_REQUEST_FIELDS = new Set([ "request_kind", "request_version", "selection", + "source_declaration_snapshot", "source_binding", "storage_binding", "trace_context", ]); const SUPPORTED_PENDING_CLIENT_FIELDS = new Set(["client_display", "client_id", "registration_mode"]); -const SUPPORTED_ACCESS_MODES = new Set(["single_use", "continuous"]); const SUPPORTED_PENDING_SELECTION_FIELDS = new Set([ "access_mode", + "client_claims", "purpose_code", "purpose_description", "retention", + "selection_preset", "streams", "type", ]); @@ -777,6 +916,35 @@ function cloneJson<T>(value: T): T { return value === null || value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); } +interface ContractSchemaError { + instancePath?: string; + message?: string; +} + +interface ContractSchemaValidator { + errors?: ContractSchemaError[] | null; + (value: unknown): boolean; +} + +interface ContractAjv { + compile: (schema: object) => ContractSchemaValidator; +} + +const requireFromReferenceContract = createRequire(import.meta.resolve("@pdpp/reference-contract")); +const ContractAjv2020 = requireFromReferenceContract("ajv/dist/2020.js") as new ( + options?: Record<string, unknown> +) => ContractAjv; +const addContractFormats = requireFromReferenceContract("ajv-formats") as (ajv: ContractAjv) => void; +const contractAjv = new ContractAjv2020({ allErrors: true, strict: false }); +addContractFormats(contractAjv); +const validateResolvedGrantContract = contractAjv.compile(ResolvedGrantSchema); + +function contractValidationMessage(validator: ContractSchemaValidator): string { + return (validator.errors ?? []) + .map((error) => `${error.instancePath || "/"} ${error.message || "is invalid"}`) + .join("; "); +} + function bindingError(code: string, message: string): AuthError { const err: AuthError = new Error(message); err.code = code; @@ -802,10 +970,6 @@ function requireMutationQuery(query: RegisteredQuery | undefined, contractName: return query; } -function isNonEmptyStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString); -} - async function forEachSequential<T>( values: readonly T[], operation: (value: T, index: number) => Promise<void>, @@ -826,11 +990,242 @@ function getManifestStreams(manifest: DbRow): Record<string, unknown>[] { return Array.isArray(manifest.streams) ? manifest.streams.filter(isRecord) : []; } -function requireManifestVersion(manifest: DbRow): string { +const CURRENT_GRANT_PACKAGE_VERSION = "reference.mcp_package.v2"; +const LEGACY_CONNECTOR_PROJECTION_PUBLISHER_ID = "https://pdpp.dev/reference-implementation"; +const CANONICAL_NON_NEGATIVE_INTEGER_KEY_RE = /^(0|[1-9][0-9]*)$/; + +function retainableSourceDeclaration( + sourceBinding: SourceBinding, + manifest: DbRow +): { declaration: DbRow; declarationVersion: string } { + if (manifest.source_declaration !== undefined) { + const declaration = snapshotSourceDeclaration(manifest.source_declaration); + if (declaration.source.id !== sourceBinding.id || declaration.source.kind !== sourceBinding.kind) { + throw bindingError("invalid_request", "Configured SourceDeclaration does not match the requested source"); + } + return { + declaration: declaration as unknown as DbRow, + declarationVersion: declaration.declaration_version, + }; + } + + if (sourceBinding.kind !== "connector") { + throw bindingError("invalid_request", "The requested source has no retained SourceDeclaration"); + } + + const connectorImplementationId = isNonEmptyString(manifest.manifest_uri) ? manifest.manifest_uri : sourceBinding.id; if (!isNonEmptyString(manifest.version)) { - throw bindingError("invalid_request", "Manifest version is required"); + throw bindingError("invalid_request", "Connector manifest version must be a non-empty string"); + } + const declaration = snapshotContentAddressedSourceDeclarationFromLegacyConnectorManifest(manifest, { + connectorImplementationId, + publisherId: LEGACY_CONNECTOR_PROJECTION_PUBLISHER_ID, + sourceId: sourceBinding.id, + }); + return { + declaration: declaration as unknown as DbRow, + declarationVersion: declaration.declaration_version, + }; +} + +interface RetainedSourceDeclarationTrust { + accepted_revision_reference?: string; + publisher_attribution: SourcePublisherAttribution; + resource_authority: SourceResourceAuthority; +} + +interface AcceptedProviderNativeRevisionForConsent { + declaration: DbRow; + source: SourceBinding; + trust: RetainedSourceDeclarationTrust & { accepted_revision_reference: string }; +} + +async function prepareInitiateGrantOptions(opts: InitiateGrantOptions): Promise<InitiateGrantOptions> { + const { acceptedRevisionReference, acceptedRevisionStore } = opts; + if (!(acceptedRevisionReference || acceptedRevisionStore)) { + return opts; + } + if (!(isNonEmptyString(acceptedRevisionReference) && acceptedRevisionStore)) { + throw bindingError( + "invalid_request", + "Accepted provider-native revision reference and revision store must be supplied together" + ); + } + if (opts.acceptedProviderNativeRevision) { + throw bindingError("invalid_request", "Accepted provider-native revision was supplied through two authority paths"); + } + if (resolveConfiguredNativeManifest(opts) && opts.nativeManifestMode !== "fulfillment_only") { + throw bindingError( + "invalid_request", + "Accepted provider-native revisions require nativeManifest to be explicitly marked fulfillment_only" + ); + } + + const accepted = await getAcceptedProviderNativeDeclarationRevision( + { acceptedRevisionReference }, + { revisionStore: acceptedRevisionStore } + ); + if (!accepted) { + throw bindingError("invalid_request", "Accepted provider-native declaration revision was not found"); + } + const declaration = snapshotSourceDeclaration(accepted.parsedDeclaration); + if ( + accepted.acceptedRevisionReference !== acceptedRevisionReference || + accepted.declarationVersion !== declaration.declaration_version || + accepted.sourceId !== declaration.source.id || + declaration.source.kind !== "provider_native" || + !isNonEmptyString(accepted.authorityBinding) + ) { + throw bindingError("invalid_request", "Accepted provider-native declaration revision does not match the request"); + } + + return { + ...opts, + acceptedProviderNativeRevision: { + declaration: declaration as unknown as DbRow, + source: { id: declaration.source.id, kind: "provider_native" }, + trust: { + accepted_revision_reference: accepted.acceptedRevisionReference, + publisher_attribution: { id: declaration.publisher.id, status: "unverified" }, + resource_authority: { authority_binding: accepted.authorityBinding, status: "verified" }, + }, + }, + }; +} + +function localOperatorProvisioningTrust(declaration: DbRow): RetainedSourceDeclarationTrust { + const publisher = isRecord(declaration.publisher) ? declaration.publisher.id : null; + if (!isNonEmptyString(publisher)) { + throw bindingError("invalid_request", "Operator-provisioned SourceDeclaration publisher is invalid"); + } + return { + publisher_attribution: { id: publisher, status: "unverified" }, + resource_authority: { status: "local_operator_provisioned" }, + }; +} + +/** + * Persistence boundary for the declaration retained with a pending consent. + * Today the carrier is params_json; keeping construction and reads here lets + * the store move it to a dedicated column without changing consent logic. + */ +async function retainSourceDeclarationSnapshot( + request: PendingRequest, + sourceBinding: SourceBinding, + storageBinding: StorageBinding, + manifest: DbRow, + opts: InitiateGrantOptions = {} +): Promise<SourceDeclarationSnapshot> { + const preparedAccepted = opts.acceptedProviderNativeRevision ?? null; + const accepted = preparedAccepted && preparedAccepted.source.id === sourceBinding.id ? preparedAccepted : null; + if (accepted && accepted.source.kind !== sourceBinding.kind) { + throw bindingError("invalid_request", "Accepted provider-native declaration revision does not match the request"); + } + const retained = accepted + ? { declaration: accepted.declaration, declarationVersion: accepted.declaration.declaration_version as string } + : await retainableSourceDeclaration(sourceBinding, manifest); + const coreSnapshot = createRetainedCoreConsentSnapshot({ + declaration: retained.declaration, + selection: request.selection as unknown as import("./core-source-authorization.ts").CoreSelection, + source: sourceBinding, + sourceSensitivity: resolveManifestSensitivity(manifest), + }) as unknown as SourceDeclarationSnapshot; + let trust: RetainedSourceDeclarationTrust | null = accepted ? accepted.trust : null; + if (!trust && sourceBinding.kind === "provider_native") { + if ( + opts.nativeManifestMode !== "local_operator_provisioning" || + !isConfiguredFulfillment(sourceBinding, storageBinding, opts) + ) { + throw bindingError( + "invalid_request", + "Provider-native consent requires an accepted revision or explicit local operator provisioning" + ); + } + trust = localOperatorProvisioningTrust(coreSnapshot.declaration); + } + const snapshot: SourceDeclarationSnapshot = trust ? { ...coreSnapshot, ...trust } : coreSnapshot; + request.source_declaration_snapshot = snapshot; + request.manifest_version = retained.declarationVersion; + return snapshot; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This is one fail-closed persistence evidence boundary; splitting its coupled shape checks would obscure the invariant. +function readRetainedSourceDeclarationSnapshot(request: Partial<PendingRequest>): SourceDeclarationSnapshot { + if (!request.selection) { + throw bindingError("invalid_request", "Pending consent selection is missing"); + } + try { + if (!isRecord(request.source_declaration_snapshot)) { + throw new Error("Pending consent declaration snapshot is missing"); + } + const { + accepted_revision_reference: acceptedRevisionReference, + publisher_attribution: publisherAttribution, + resource_authority: resourceAuthority, + ...coreSnapshot + } = request.source_declaration_snapshot; + const retained = readRetainedCoreConsentSnapshot({ + selection: request.selection as unknown as import("./core-source-authorization.ts").CoreSelection, + snapshot: coreSnapshot, + source: request.source_binding, + }) as unknown as SourceDeclarationSnapshot; + const hasTrustEvidence = + acceptedRevisionReference !== undefined || publisherAttribution !== undefined || resourceAuthority !== undefined; + if (retained.source.kind !== "provider_native") { + if (hasTrustEvidence) { + throw new Error("Connector declaration snapshot must not contain provider-native trust evidence"); + } + return retained; + } + if (!(isRecord(publisherAttribution) && publisherAttribution.status === "unverified")) { + throw new Error("Provider-native publisher attribution evidence is missing or invalid"); + } + const declarationPublisher = isRecord(retained.declaration.publisher) ? retained.declaration.publisher.id : null; + if (!isNonEmptyString(publisherAttribution.id) || publisherAttribution.id !== declarationPublisher) { + throw new Error("Provider-native publisher attribution does not match the retained declaration"); + } + if (!isRecord(resourceAuthority)) { + throw new Error("Provider-native resource authority evidence is missing"); + } + if (acceptedRevisionReference !== undefined) { + if ( + !isNonEmptyString(acceptedRevisionReference) || + resourceAuthority.status !== "verified" || + !isNonEmptyString(resourceAuthority.authority_binding) + ) { + throw new Error("Accepted provider-native resource authority evidence is invalid"); + } + const expectedReference = acceptedRevisionEvidenceReference({ + authorityBinding: resourceAuthority.authority_binding as string, + declarationVersion: retained.declaration_version, + sourceId: retained.source.id, + }); + if (acceptedRevisionReference !== expectedReference) { + throw new Error("Accepted provider-native revision evidence is stale or tampered"); + } + } else if ( + resourceAuthority.status !== "local_operator_provisioned" || + Object.keys(resourceAuthority).some((key) => key !== "status") + ) { + throw new Error("Operator-provisioned provider-native authority evidence is invalid"); + } + return { + ...retained, + ...(acceptedRevisionReference ? { accepted_revision_reference: acceptedRevisionReference } : {}), + publisher_attribution: { id: publisherAttribution.id, status: "unverified" }, + resource_authority: + resourceAuthority.status === "verified" + ? { authority_binding: resourceAuthority.authority_binding as string, status: "verified" } + : { status: "local_operator_provisioned" }, + }; + } catch (cause: unknown) { + const err = bindingError( + "invalid_request", + cause instanceof Error ? cause.message : "Pending consent snapshot is invalid" + ); + err.cause = cause; + throw err; } - return manifest.version; } type AuthSpineEventInput = { @@ -851,6 +1246,32 @@ function resolveConfiguredNativeStorageBinding(opts: { nativeManifest?: DbRow | return isNonEmptyString(connectorId) ? { connector_id: connectorId } : null; } +function resolveConfiguredSourceBinding(opts: { nativeManifest?: DbRow | null } = {}): SourceBinding | null { + const manifest = resolveConfiguredNativeManifest(opts); + if (!isRecord(manifest?.source_declaration)) { + return null; + } + const declaration = snapshotSourceDeclaration(manifest.source_declaration); + return { id: declaration.source.id, kind: declaration.source.kind }; +} + +function isConfiguredFulfillment( + sourceBinding: SourceBinding, + storageBinding: StorageBinding, + opts: { nativeManifest?: DbRow | null } = {} +): boolean { + const configuredSource = resolveConfiguredSourceBinding(opts); + const configuredStorage = resolveConfiguredNativeStorageBinding(opts); + return configuredSource?.id === sourceBinding.id && configuredStorage?.connector_id === storageBinding.connector_id; +} + +function isConfiguredStorageFulfillment( + storageBinding: StorageBinding, + opts: { nativeManifest?: DbRow | null } = {} +): boolean { + return resolveConfiguredNativeStorageBinding(opts)?.connector_id === storageBinding.connector_id; +} + export function buildPendingConsentRequestUri(deviceCode: string): string { return `${PENDING_CONSENT_REQUEST_URI_PREFIX}${deviceCode}`; } @@ -1113,11 +1534,10 @@ function applyRegisteredClientToPendingRequestClient( }); } -function normalizeStreamSelection(stream: Record<string, unknown>): RawStreamSelection { +function normalizeStreamSelection(stream: RawStreamSelection): RawStreamSelection { return { - client_claims: stream.client_claims || undefined, - connection_id: typeof stream.connection_id === "string" && stream.connection_id ? stream.connection_id : undefined, fields: Array.isArray(stream.fields) ? stream.fields : undefined, + instance_ids: Array.isArray(stream.instance_ids) ? stream.instance_ids : undefined, name: stream.name, necessity: stream.necessity || undefined, resources: Array.isArray(stream.resources) ? stream.resources : undefined, @@ -1184,125 +1604,171 @@ function isAbsoluteUriPurposeCode(value: unknown): boolean { interface AuthorizationDetailInput extends Record<string, unknown> { access_mode: string; - streams: Record<string, unknown>[]; + selection_preset?: string; + streams?: RawStreamSelection[]; } -function requireAuthorizationDetailInput(detail: unknown, index: number): AuthorizationDetailInput { - const at = `authorization_details[${index}]`; - if (!isRecord(detail)) { - throw bindingError("invalid_request", "Unsupported authorization_details type"); +function requireAuthorizationDetailInput(detail: unknown, _index: number): AuthorizationDetailInput { + const validated = validateCoreSelectionRequest(detail); + const hasPreset = isNonEmptyString(validated.selection_preset); + return { + ...validated, + access_mode: validated.access_mode, + ...(hasPreset + ? { selection_preset: validated.selection_preset as string } + : { streams: (validated.streams ?? []).map((stream) => ({ ...stream })) }), + }; +} + +async function resolveRegisteredSourceStorageConnectorId(sourceBinding: SourceBinding): Promise<string | null> { + const rows = await getConnectorCatalogStore().listBySourceId(sourceBinding.id); + if (rows.length === 0) { + return null; } - if (detail.type !== "https://pdpp.dev/data-access") { - invalidGrantInitiationRequest("Unsupported authorization_details type"); + if (rows.length > 1) { + throw bindingError("invalid_request", `Source '${sourceBinding.id}' has multiple local fulfillment bindings`); } - if ("connector_id" in detail || "provider_id" in detail) { - invalidGrantInitiationRequest( - "authorization_details must use source: { kind: 'connector' | 'provider_native', id }" - ); + const [row] = rows; + if (!(row && isNonEmptyString(row.connector_id))) { + throw bindingError("invalid_request", `Source '${sourceBinding.id}' has an invalid local fulfillment binding`); } - const unsupportedDetailFields = Object.keys(detail).filter( - (field) => !SUPPORTED_AUTHORIZATION_DETAIL_FIELDS.has(field) - ); - if (unsupportedDetailFields.length) { - invalidGrantInitiationRequest(`Unsupported authorization_details fields: ${unsupportedDetailFields.join(", ")}`); + const manifest = parseAndValidateConnectorManifestRow(row, row.connector_id); + const declaration = snapshotSourceDeclaration(manifest.source_declaration); + if (declaration.source.id !== sourceBinding.id || declaration.source.kind !== sourceBinding.kind) { + invalidGrantInitiationRequest(`Source kind does not match the retained declaration for '${sourceBinding.id}'`); } - if (!Array.isArray(detail.streams) || detail.streams.length === 0) { - throw bindingError("invalid_request", `${at}.streams must be a non-empty array`); + return row.connector_id; +} + +async function resolveRegisteredSourceBindingById(sourceId: string): Promise<{ + sourceBinding: SourceBinding; + storageConnectorId: string; +} | null> { + const rows = await getConnectorCatalogStore().listBySourceId(sourceId); + if (rows.length === 0) { + return null; } - if (typeof detail.access_mode !== "string" || !SUPPORTED_ACCESS_MODES.has(detail.access_mode)) { - throw bindingError("invalid_request", `${at}.access_mode must be "single_use" or "continuous"`); + if (rows.length > 1) { + throw bindingError("invalid_request", `Source '${sourceId}' has multiple local fulfillment bindings`); } - // purpose_code must be a syntactically valid absolute URI (spec-core.md:428). - // The AS validates SYNTAX only here; it MUST NOT reject a code merely for being - // unrecognized. Registry membership is advisory, enforced (if at all) by local - // policy elsewhere. - if (detail.purpose_code !== undefined && !isAbsoluteUriPurposeCode(detail.purpose_code)) { - invalidGrantInitiationRequest(`${at}.purpose_code must be a syntactically valid absolute URI`); + const [row] = rows; + if (!(row && isNonEmptyString(row.connector_id))) { + throw bindingError("invalid_request", `Source '${sourceId}' has an invalid local fulfillment binding`); } - const streams: Record<string, unknown>[] = []; - for (const stream of detail.streams) { - if (!isRecord(stream)) { - invalidGrantInitiationRequest(`${at}.streams entries must be objects`); - } - const unsupportedStreamFields = Object.keys(stream).filter( - (field) => !SUPPORTED_STREAM_SELECTION_FIELDS.has(field) - ); - if (unsupportedStreamFields.length) { - invalidGrantInitiationRequest( - `Unsupported stream selection fields on '${stream.name || "unknown"}': ${unsupportedStreamFields.join(", ")}` - ); - } - streams.push(stream); + const manifest = parseAndValidateConnectorManifestRow(row, row.connector_id); + const declaration = snapshotSourceDeclaration(manifest.source_declaration); + if (declaration.source.id !== sourceId) { + invalidGrantInitiationRequest(`Source id does not match the retained declaration for '${sourceId}'`); } - return { ...detail, access_mode: detail.access_mode, streams }; + return { + sourceBinding: { id: declaration.source.id, kind: declaration.source.kind }, + storageConnectorId: row.connector_id, + }; } -function resolveAuthorizationDetailBindings( +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Existing request/declaration compatibility boundary; keep validation local. +async function resolveAuthorizationDetailBindings( detail: AuthorizationDetailInput, index: number, - opts: { nativeManifest?: DbRow | null } -): { sourceBinding: SourceBinding; storageBinding: StorageBinding } { + opts: InitiateGrantOptions +): Promise<{ sourceBinding: SourceBinding; storageBinding: StorageBinding }> { const at = `authorization_details[${index}]`; - const nativeManifest = resolveConfiguredNativeManifest(opts); - const configuredNativeProviderId = nativeManifest?.provider_id || null; + const acceptedSource = opts.acceptedProviderNativeRevision ? opts.acceptedProviderNativeRevision.source : null; + const configuredSource = opts.nativeManifestMode === "fulfillment_only" ? null : resolveConfiguredSourceBinding(opts); const configuredNativeStorageBinding = resolveConfiguredNativeStorageBinding(opts); const configuredNativeStorageConnectorId = configuredNativeStorageBinding?.connector_id || null; const detailSource = detail.source; if (!isRecord(detailSource)) { - throw bindingError("invalid_request", `${at}.source must be { kind: 'connector' | 'provider_native', id }`); + throw bindingError( + "invalid_request", + `${at}.source must be { id } or { kind: 'connector' | 'provider_native', id }` + ); } const detailSourceKeys = Object.keys(detailSource).sort(); - if (detailSourceKeys.length !== 2 || detailSourceKeys[0] !== "id" || detailSourceKeys[1] !== "kind") { - invalidGrantInitiationRequest(`${at}.source must include only kind and id`); + const sourceHasKind = detailSourceKeys.includes("kind"); + if ( + !( + (detailSourceKeys.length === 1 && detailSourceKeys[0] === "id") || + (detailSourceKeys.length === 2 && detailSourceKeys[0] === "id" && detailSourceKeys[1] === "kind") + ) + ) { + invalidGrantInitiationRequest(`${at}.source must include only id and optional kind`); } - const bindingKind = detailSource.kind; const sourceId = detailSource.id; - if (!((bindingKind === "connector" || bindingKind === "provider_native") && isNonEmptyString(sourceId))) { + const explicitKind = sourceHasKind ? detailSource.kind : null; + if ( + !( + (explicitKind === null || explicitKind === "connector" || explicitKind === "provider_native") && + isNonEmptyString(sourceId) + ) + ) { throw bindingError( "invalid_request", - `${at}.source.kind must be 'connector' or 'provider_native' and source.id is required` + `${at}.source.kind must be 'connector' or 'provider_native' when present and source.id is required` ); } - if (bindingKind === "provider_native" && configuredNativeProviderId && sourceId !== configuredNativeProviderId) { - invalidGrantInitiationRequest(`Unknown source: { kind: 'provider_native', id: '${sourceId}' }`); - } - // Normalize URL-shaped first-party connector ids to their canonical short - // keys at the grant-initiation boundary so pending consents and issued - // grants always store a canonical connector_id, not a registry URL. - // Unknown / custom connector ids are preserved as-is (fail open) so - // third-party manifests continue to work without being in the allowlist. - const rawSourceConnectorId = bindingKind === "connector" ? sourceId : configuredNativeStorageConnectorId; - const resolvedConnectorId = rawSourceConnectorId - ? (canonicalConnectorKey(rawSourceConnectorId) ?? rawSourceConnectorId) - : rawSourceConnectorId; + if (!isAbsoluteUriPurposeCode(sourceId)) { + throw bindingError("invalid_request", `${at}.source.id must be an absolute URI`); + } + const selectsConfiguredSource = configuredSource?.id === sourceId; + const selectsAcceptedSource = acceptedSource !== null && acceptedSource.id === sourceId; + if (selectsAcceptedSource && explicitKind && explicitKind !== "provider_native") { + invalidGrantInitiationRequest(`Source kind does not match the accepted declaration for '${sourceId}'`); + } + if (selectsConfiguredSource && explicitKind && configuredSource.kind !== explicitKind) { + invalidGrantInitiationRequest(`Source kind does not match the retained declaration for '${sourceId}'`); + } + let sourceBinding: SourceBinding = { + id: sourceId, + kind: explicitKind || (acceptedSource ? acceptedSource.kind : null) || configuredSource?.kind || "connector", + }; + let rawSourceConnectorId: string | null = null; + if (selectsAcceptedSource) { + if (opts.nativeManifestMode !== "fulfillment_only") { + throw bindingError( + "invalid_request", + "Accepted provider-native source has no explicit local fulfillment binding" + ); + } + sourceBinding = acceptedSource; + rawSourceConnectorId = configuredNativeStorageConnectorId; + } else if (selectsConfiguredSource) { + rawSourceConnectorId = configuredNativeStorageConnectorId; + } else { + const registered = explicitKind + ? { sourceBinding, storageConnectorId: await resolveRegisteredSourceStorageConnectorId(sourceBinding) } + : await resolveRegisteredSourceBindingById(sourceId); + if (registered?.sourceBinding) { + ({ sourceBinding } = registered); + } + rawSourceConnectorId = registered?.storageConnectorId ?? null; + } + const resolvedConnectorId = rawSourceConnectorId; if (!resolvedConnectorId) { - throw bindingError("invalid_request", `${at}.source requires configured native storage for provider_native access`); + throw bindingError("invalid_request", `Unknown source: { id: '${sourceId}' }`); } - // Use the canonical connector id in the source binding too so that - // source_binding.id === storage_binding.connector_id, which the approval - // path validates. For provider_native grants the source id is the - // provider_id (not a connector_id), so we only normalize connector sources. - const canonicalSourceId = bindingKind === "connector" ? (canonicalConnectorKey(sourceId) ?? sourceId) : sourceId; - const sourceBinding: SourceBinding = { id: canonicalSourceId, kind: bindingKind }; return { sourceBinding, storageBinding: { connector_id: resolvedConnectorId } }; } -function normalizeAuthorizationDetail( +async function normalizeAuthorizationDetail( rawDetail: unknown, index: number, - opts: { nativeManifest?: DbRow | null } = {} -): { selection: GrantSelection; source_binding: SourceBinding; storage_binding: StorageBinding } { + opts: InitiateGrantOptions = {} +): Promise<{ selection: GrantSelection; source_binding: SourceBinding; storage_binding: StorageBinding }> { const detail = requireAuthorizationDetailInput(rawDetail, index); - const { sourceBinding, storageBinding } = resolveAuthorizationDetailBindings(detail, index, opts); + const { sourceBinding, storageBinding } = await resolveAuthorizationDetailBindings(detail, index, opts); return { selection: { access_mode: detail.access_mode, - purpose_code: isNonEmptyString(detail.purpose_code) ? detail.purpose_code : undefined, + client_claims: detail.client_claims || undefined, + purpose_code: detail.purpose_code as string, purpose_description: isNonEmptyString(detail.purpose_description) ? detail.purpose_description : undefined, retention: detail.retention || undefined, - streams: detail.streams.map(normalizeStreamSelection), + ...(detail.selection_preset + ? { selection_preset: detail.selection_preset } + : { streams: (detail.streams ?? []).map(normalizeStreamSelection) }), type: "https://pdpp.dev/data-access", }, source_binding: sourceBinding, @@ -1310,10 +1776,10 @@ function normalizeAuthorizationDetail( }; } -function normalizePendingGrantRequest( +async function normalizePendingGrantRequest( input: Record<string, unknown>, - opts: { nativeManifest?: DbRow | null } = {} -): PendingRequest { + opts: InitiateGrantOptions = {} +): Promise<PendingRequest> { const envelope = requireStagedRequestEnvelope(input); const clientId = envelope.client_id; if (envelope.authorization_details.length !== 1) { @@ -1326,7 +1792,13 @@ function normalizePendingGrantRequest( if (input.parent_package_id !== undefined && input.parent_package_id !== null) { invalidGrantInitiationRequest("parent_package_id is only supported on the staged batch path"); } - const entry = normalizeAuthorizationDetail(envelope.authorization_details[0], 0, opts); + const entry = await normalizeAuthorizationDetail(envelope.authorization_details[0], 0, opts); + if ( + opts.acceptedProviderNativeRevision && + entry.source_binding.id !== opts.acceptedProviderNativeRevision.source.id + ) { + invalidGrantInitiationRequest("Accepted provider-native declaration revision does not match the requested source"); + } return { client: { client_display: normalizeClientDisplay(input.client_display), @@ -1340,15 +1812,29 @@ function normalizePendingGrantRequest( }; } -function normalizeStagedGrantRequestBatch( +async function normalizeStagedGrantRequestBatch( input: Record<string, unknown>, - opts: { nativeManifest?: DbRow | null } = {} -): StagedBatchRequest { + opts: InitiateGrantOptions = {} +): Promise<StagedBatchRequest> { const envelope = requireStagedRequestEnvelope(input); const clientId = envelope.client_id; - const entries = envelope.authorization_details.map((detail, index) => - normalizeAuthorizationDetail(detail, index, opts) + const entries = await Promise.all( + envelope.authorization_details.map((detail, index) => normalizeAuthorizationDetail(detail, index, opts)) ); + if (opts.acceptedProviderNativeRevision) { + const acceptedSourceId = opts.acceptedProviderNativeRevision.source.id; + const acceptedSourceCount = entries.filter((entry) => entry.source_binding.id === acceptedSourceId).length; + if (acceptedSourceCount === 0) { + invalidGrantInitiationRequest( + "Accepted provider-native declaration revision does not match any requested source" + ); + } + if (acceptedSourceCount > 1) { + invalidGrantInitiationRequest( + "One accepted provider-native declaration revision can authorize only one staged source" + ); + } + } const entryCount = entries.length; const overSoftCap = entryCount > BATCH_CONSENT_STAGED_ENTRY_SOFT_CAP; // The soft cap is a reference-contract policy constant, not a hard limit @@ -1451,15 +1937,52 @@ function buildPendingRequestRejectionData( request: Partial<PendingRequest> = {}, pending: DbRow = {} ): Record<string, unknown> { + const snapshot = request.source_declaration_snapshot; return { access_mode: request.selection?.access_mode || null, purpose_code: request.selection?.purpose_code || null, source: describeSourceBinding(getRequestSourceBinding(request)), stream_names: (request.selection?.streams || []).map((stream) => stream.name), + ...(isRecord(snapshot) + ? { + source_declaration_snapshot: { + declaration_version: snapshot.declaration_version, + snapshot_version: snapshot.snapshot_version, + source: snapshot.source, + }, + } + : {}), user_code: pending.user_code, }; } +function buildResolvedSnapshotEvidence( + request: Partial<PendingRequest>, + resolvedStreams: ResolvedGrantStream[] +): Record<string, unknown> { + const snapshot = readRetainedSourceDeclarationSnapshot(request); + return { + resolved_streams: resolvedStreams.map((stream) => ({ + fields: [...(stream.fields ?? [])], + instance_ids: [...stream.instance_ids], + name: stream.name, + ...(stream.resources ? { resources: cloneJson(stream.resources) } : {}), + ...(stream.time_constraint ? { time_constraint: cloneJson(stream.time_constraint) } : {}), + })), + source_declaration_snapshot: { + ...(snapshot.accepted_revision_reference + ? { accepted_revision_reference: snapshot.accepted_revision_reference } + : {}), + declaration: snapshot.declaration, + declaration_version: snapshot.declaration_version, + ...(snapshot.publisher_attribution ? { publisher_attribution: snapshot.publisher_attribution } : {}), + ...(snapshot.resource_authority ? { resource_authority: snapshot.resource_authority } : {}), + snapshot_version: snapshot.snapshot_version, + source: snapshot.source, + }, + }; +} + async function emitPendingConsentRejected( request: Partial<PendingRequest>, pending: DbRow, @@ -1499,6 +2022,14 @@ async function emitPendingConsentRejected( return err; } +function requirePendingSelectionSelector(selection: GrantSelection): void { + const hasStreams = Array.isArray(selection.streams) && selection.streams.length > 0; + const hasPreset = isNonEmptyString(selection.selection_preset); + if (hasStreams === hasPreset) { + throw bindingError("invalid_request", "selection must include exactly one of streams or selection_preset"); + } +} + function requireStructuredPendingRequestShape(request: unknown): asserts request is PendingRequest { if (!isRecord(request)) { throw bindingError("invalid_request", "pending request must be an object"); @@ -1542,13 +2073,12 @@ function requireStructuredPendingRequestShape(request: unknown): asserts request if (request.selection.type !== "https://pdpp.dev/data-access") { throw bindingError("invalid_request", "selection.type must be https://pdpp.dev/data-access"); } - if (!Array.isArray(request.selection.streams) || request.selection.streams.length === 0) { - throw bindingError("invalid_request", "selection.streams must be a non-empty array"); - } + requirePendingSelectionSelector(request.selection as unknown as GrantSelection); if (!isNonEmptyString(request.selection.access_mode)) { throw bindingError("invalid_request", "selection.access_mode is required"); } - for (const stream of request.selection.streams) { + const selectedStreams = Array.isArray(request.selection.streams) ? request.selection.streams : []; + for (const stream of selectedStreams) { if (!stream || typeof stream !== "object") { throw bindingError("invalid_request", "selection.streams entries must be objects"); } @@ -1623,30 +2153,6 @@ function requireStructuredPendingRequestBindings(request: Partial<PendingRequest throw bindingError("invalid_request", "storage_binding must include only connector_id"); } - if (sourceBinding.kind === "connector" && sourceBinding.id !== storageBinding.connector_id) { - throw bindingError( - "invalid_request", - "source_binding.id must match storage_binding.connector_id for connector access" - ); - } - - if (sourceBinding.kind === "provider_native") { - const nativeManifest = resolveConfiguredNativeManifest(); - const nativeStorageBinding = resolveConfiguredNativeStorageBinding(); - if (!(nativeManifest?.provider_id && nativeStorageBinding?.connector_id)) { - throw bindingError("invalid_request", "native provider access requires a configured native manifest"); - } - if (sourceBinding.id !== nativeManifest.provider_id) { - throw bindingError("invalid_request", "source_binding.id must match the configured native provider"); - } - if (storageBinding.connector_id !== nativeStorageBinding.connector_id) { - throw bindingError( - "invalid_request", - "storage_binding.connector_id must match the configured native storage binding" - ); - } - } - return { sourceBinding, storageBinding }; } @@ -1670,112 +2176,69 @@ function requireGrantManifestForBindings( }); } -function requireRequestedManifestStream( - manifest: DbRow, - manifestStreams: Record<string, unknown>[], - streamName: string -): Record<string, unknown> { - try { - assertManifestReadAuthority(manifest, streamName, { actor: "client" }); - const manifestStream = manifestStreams.find((stream) => stream.name === streamName); - if (manifestStream) { - return manifestStream; - } - } catch (cause: unknown) { - const err = bindingError("invalid_request", `Unknown stream: ${streamName}`); - err.cause = cause; - throw err; - } - throw bindingError("invalid_request", `Unknown stream: ${streamName}`); -} - -function resolveRequestedView( - streamRequest: RawStreamSelection, - manifestStream: Record<string, unknown>, - streamName: string -): Pick<StreamSelection, "fields" | "view"> { - if (!isNonEmptyString(streamRequest.view)) { - throw bindingError("invalid_request", `Unknown view '${streamRequest.view}' on stream '${streamName}'`); - } - const manifestViews = Array.isArray(manifestStream.views) ? manifestStream.views.filter(isRecord) : []; - const viewDef = manifestViews.find((view) => view.id === streamRequest.view); - if (!(viewDef && isNonEmptyStringArray(viewDef.fields))) { - throw bindingError("invalid_request", `Unknown view '${streamRequest.view}' on stream '${streamName}'`); - } - return { fields: viewDef.fields, view: streamRequest.view }; -} - -function resolveRequestedFields( - streamRequest: RawStreamSelection, - manifestStream: Record<string, unknown>, - streamName: string -): Pick<StreamSelection, "fields"> { - if (!(isRecord(manifestStream.selection) && manifestStream.selection.fields)) { - throw bindingError("invalid_request", `Stream '${streamName}' does not support field-level selection`); - } - if (!isNonEmptyStringArray(streamRequest.fields)) { - throw bindingError("invalid_request", `Stream '${streamName}' fields must be a non-empty array of field names`); - } - const schemaProperties = - isRecord(manifestStream.schema) && isRecord(manifestStream.schema.properties) - ? manifestStream.schema.properties - : {}; - const allowedFields = new Set(Object.keys(schemaProperties)); - const unknownFields = streamRequest.fields.filter((field) => !allowedFields.has(field)); - if (unknownFields.length) { - throw bindingError("invalid_request", `Unknown fields on stream '${streamName}': ${unknownFields.join(", ")}`); - } - return { fields: streamRequest.fields }; -} - -function resolveGrantStream( - streamRequest: RawStreamSelection, - manifest: DbRow, - manifestStreams: Record<string, unknown>[] -): StreamSelection { - if (!isNonEmptyString(streamRequest.name)) { - throw bindingError("invalid_request", "Stream name must be a non-empty string"); - } - const streamName = streamRequest.name; - const manifestStream = requireRequestedManifestStream(manifest, manifestStreams, streamName); - if (streamRequest.time_range && !manifestStream.consent_time_field) { - throw bindingError("invalid_request", `Stream '${streamName}' does not support time_range (no consent_time_field)`); - } - if (streamRequest.view && streamRequest.fields) { - throw bindingError("invalid_request", `Stream '${streamName}' view and fields are mutually exclusive`); - } - - let projection: Pick<StreamSelection, "fields" | "view"> = {}; - if (streamRequest.view) { - projection = resolveRequestedView(streamRequest, manifestStream, streamName); - } else if (streamRequest.fields) { - projection = resolveRequestedFields(streamRequest, manifestStream, streamName); +async function resolveEligibleInstanceIdsForApproval( + streams: StreamSelection[], + sourceBinding: SourceBinding, + storageBinding: StorageBinding, + subjectId: string, + opts: { acceptedRevisionFulfillment?: boolean } = {} +): Promise<StreamSelection[]> { + const connectorInstanceStore = isPostgresStorageBackend() + ? createPostgresConnectorInstanceStore() + : createSqliteConnectorInstanceStore(); + const configuredFulfillment = opts.acceptedRevisionFulfillment + ? isConfiguredStorageFulfillment(storageBinding) + : isConfiguredFulfillment(sourceBinding, storageBinding); + const configuredDefaultInstanceId = configuredFulfillment + ? makeDefaultAccountConnectorInstanceId(subjectId, storageBinding.connector_id) + : null; + const explicitIds = Array.from(new Set(streams.flatMap((stream) => stream.instance_ids ?? []))); + const explicitBindings = await Promise.all( + explicitIds.map(async (connectorInstanceId) => { + try { + const binding = await resolveOwnerConnectorInstanceNamespace({ + allowDefaultAccount: false, + connectorId: storageBinding.connector_id, + connectorInstanceId, + connectorInstanceStore, + ownerSubjectId: subjectId, + }); + return [connectorInstanceId, binding.connectorInstanceId] as const; + } catch (error) { + if (configuredDefaultInstanceId === connectorInstanceId) { + return [connectorInstanceId, connectorInstanceId] as const; + } + if (configuredFulfillment) { + throw bindingError( + "invalid_request", + `Configured source instance_ids must equal its configured local instance '${configuredDefaultInstanceId}'` + ); + } + throw error; + } + }) + ); + const eligibleInstanceIds = new Set(explicitBindings.map(([, instanceId]) => instanceId)); + if (streams.some((stream) => (stream.instance_ids ?? []).length === 0)) { + const activeBindings = await connectorInstanceStore.listActiveByConnector(subjectId, storageBinding.connector_id, { + limit: 2, + }); + if (activeBindings.length === 0 && configuredDefaultInstanceId) { + eligibleInstanceIds.add(configuredDefaultInstanceId); + } else { + for (const binding of activeBindings) { + eligibleInstanceIds.add(binding.connectorInstanceId); + } + } } - return { - ...projection, - ...(isRecord(streamRequest.time_range) ? { time_range: streamRequest.time_range } : {}), - ...(streamRequest.resources ? { resources: streamRequest.resources } : {}), - ...(isNonEmptyString(streamRequest.connection_id) ? { connection_id: streamRequest.connection_id } : {}), - name: streamName, - }; + return resolveCoreEligibleInstanceIds({ + eligibleInstanceIdsByStream: Object.fromEntries(streams.map((stream) => [stream.name, [...eligibleInstanceIds]])), + streams, + }) as StreamSelection[]; } -function resolveGrantSelection(selection: Partial<GrantSelection> = {}, manifest: DbRow = {}): StreamSelection[] { - let streams = selection.streams || []; - const manifestStreams = getManifestStreams(manifest); - const onlyStream = streams.length === 1 ? streams[0] : undefined; - if (onlyStream?.name === "*") { - // Expanding the wildcard MUST preserve a per-stream `connection_id` - // constraint. A wildcard pinned to a connection (the hosted MCP picker's - // whole-source approval for a chosen sibling connection) means "every - // stream, but only from this connection" — dropping the pin here would - // silently fan the grant back in across every connection of the connector. - const wildcardConnectionId = isNonEmptyString(onlyStream.connection_id) ? onlyStream.connection_id : null; - streams = manifestStreams.map((stream) => - wildcardConnectionId ? { connection_id: wildcardConnectionId, name: stream.name } : { name: stream.name } - ); - } - return streams.map((streamRequest) => resolveGrantStream(streamRequest, manifest, manifestStreams)); +function projectResolvedGrantStreams(streams: StreamSelection[]): ResolvedGrantStream[] { + return coreProjectResolvedGrantStreams(streams) as ResolvedGrantStream[]; } function requireStructuredGrantBindings( @@ -1794,30 +2257,6 @@ function requireStructuredGrantBindings( throw bindingError("grant_invalid", "grant_storage_binding must include only connector_id"); } - if (sourceBinding.kind === "connector" && sourceBinding.id !== normalizedStorageBinding.connector_id) { - throw bindingError( - "grant_invalid", - "grant.source.id must match grant_storage_binding.connector_id for connector access" - ); - } - - if (sourceBinding.kind === "provider_native") { - const nativeManifest = resolveConfiguredNativeManifest(); - const nativeStorageBinding = resolveConfiguredNativeStorageBinding(); - if (!(nativeManifest?.provider_id && nativeStorageBinding?.connector_id)) { - throw bindingError("grant_invalid", "provider-native grants require a configured native manifest"); - } - if (sourceBinding.id !== nativeManifest.provider_id) { - throw bindingError("grant_invalid", "grant.source.id must match the configured native provider"); - } - if (normalizedStorageBinding.connector_id !== nativeStorageBinding.connector_id) { - throw bindingError( - "grant_invalid", - "grant_storage_binding.connector_id must match the configured native storage binding" - ); - } - } - return { sourceBinding, storageBinding: normalizedStorageBinding }; } @@ -1899,151 +2338,136 @@ function buildGrantInvalidError(context: { request_id?: string | null; trace_id? return err; } -function hasExactFieldSet(fields: unknown[] = [], expectedFields: string[] = []): boolean { - if (!(Array.isArray(fields) && Array.isArray(expectedFields)) || fields.length !== expectedFields.length) { - return false; - } - const actual = new Set(fields); - if (actual.size !== expectedFields.length) { - return false; +function buildUnsupportedLegacyAuthorizationStateError(): AuthError { + const err: AuthError = new Error("Persisted authorization state predates PDPP 0.1.0; fresh consent is required"); + err.code = "authorization_state.unsupported_legacy_shape"; + return err; +} + +function isUnsupportedLegacyGrantShape(grant: DbRow): boolean { + if (grant.version !== "0.1.0" || !isRecord(grant.source_declaration) || !Array.isArray(grant.streams)) { + return true; } - return expectedFields.every((field) => actual.has(field)); + return grant.streams.some( + (stream) => !(isRecord(stream) && Array.isArray(stream.instance_ids) && Array.isArray(stream.fields)) + ); } -function requirePersistedGrantManifestStream( - streamGrant: Record<string, unknown>, - manifest: DbRow, - manifestStreams: Record<string, unknown>[] -): Record<string, unknown> { - const streamName = String(streamGrant.name); - try { - assertManifestReadAuthority(manifest, streamName, { actor: "client" }); - const manifestStream = manifestStreams.find((stream) => stream.name === streamName); - if (manifestStream) { - return manifestStream; +function requireClosedResolvedGrant(grant: unknown, code: "grant_invalid" | "invalid_request"): DbRow { + const candidate = cloneJson(grant); + if (!validateResolvedGrantContract(candidate)) { + throw bindingError(code, `Resolved grant is invalid: ${contractValidationMessage(validateResolvedGrantContract)}`); + } + const resolved = candidate as DbRow; + if (resolved.version !== "0.1.0") { + throw bindingError(code, "Resolved grant version is unsupported; fresh consent is required"); + } + const streams = resolved.streams ?? []; + const streamNames = streams.map((stream) => stream.name); + if (new Set(streamNames).size !== streamNames.length) { + throw bindingError(code, "Resolved grant stream names must be unique"); + } + for (const stream of streams) { + const constraint = isRecord(stream.time_constraint) ? stream.time_constraint : null; + if ( + constraint && + isNonEmptyString(constraint.since) && + isNonEmptyString(constraint.until) && + Date.parse(constraint.since) > Date.parse(constraint.until) + ) { + throw bindingError(code, `Resolved stream '${stream.name}' time_constraint.since must not follow until`); } - } catch (cause: unknown) { - const err = bindingError("grant_invalid", `Unknown stream in persisted grant: ${streamName}`); - err.cause = cause; - throw err; } - throw bindingError("grant_invalid", `Unknown stream in persisted grant: ${streamName}`); + return resolved; } -function requirePersistedGrantView( - streamGrant: Record<string, unknown>, - manifestStream: Record<string, unknown> +function requirePersistedGrantColumnBindings( + grant: DbRow, + row: DbRow, + code: "grant_invalid" | "invalid_request", + tokenBinding?: { clientId: unknown; expiresAt: unknown; grantId: unknown; subjectId: unknown } ): void { - const manifestViews = Array.isArray(manifestStream.views) ? manifestStream.views.filter(isRecord) : []; - const viewDef = manifestViews.find((view) => view.id === streamGrant.view); - if (!viewDef) { - throw bindingError( - "grant_invalid", - `Unknown persisted grant view '${streamGrant.view}' on stream '${streamGrant.name}'` - ); - } - if (!isNonEmptyStringArray(streamGrant.fields)) { - throw bindingError( - "grant_invalid", - `Persisted grant view '${streamGrant.view}' on stream '${streamGrant.name}' must include resolved fields` - ); - } - if (!(isNonEmptyStringArray(viewDef.fields) && hasExactFieldSet(streamGrant.fields, viewDef.fields))) { - throw bindingError( - "grant_invalid", - `Persisted grant view '${streamGrant.view}' on stream '${streamGrant.name}' no longer matches the manifest view definition` - ); + const client = isRecord(grant.client) ? grant.client : null; + const subject = isRecord(grant.subject) ? grant.subject : null; + const persistedGrantId = row.persisted_grant_id; + const persistedSubjectId = row.grant_subject_id; + const persistedClientId = row.grant_client_id; + const persistedAccessMode = row.grant_access_mode; + const persistedExpiresAt = row.grant_expires_at ?? null; + if ( + !( + isNonEmptyString(persistedGrantId) && + isNonEmptyString(persistedSubjectId) && + isNonEmptyString(persistedClientId) && + isNonEmptyString(persistedAccessMode) + ) || + grant.grant_id !== persistedGrantId || + client?.client_id !== persistedClientId || + subject?.id !== persistedSubjectId || + grant.access_mode !== persistedAccessMode || + (grant.expires_at ?? null) !== persistedExpiresAt + ) { + throw bindingError(code, "Resolved grant does not match its persisted grant binding"); + } + const tokenExpiresAt = tokenBinding?.expiresAt ?? null; + const tokenExpiryMillis = isNonEmptyString(tokenExpiresAt) ? Date.parse(tokenExpiresAt) : Number.NaN; + const grantExpiryMillis = isNonEmptyString(persistedExpiresAt) ? Date.parse(persistedExpiresAt) : Number.NaN; + const tokenExpiryViolatesGrant = + (tokenExpiresAt !== null && !Number.isFinite(tokenExpiryMillis)) || + (persistedExpiresAt !== null && + (tokenExpiresAt === null || !Number.isFinite(grantExpiryMillis) || tokenExpiryMillis > grantExpiryMillis)); + if ( + tokenBinding && + (tokenBinding.grantId !== persistedGrantId || + tokenBinding.subjectId !== persistedSubjectId || + tokenBinding.clientId !== persistedClientId || + tokenExpiryViolatesGrant) + ) { + throw bindingError(code, "Token binding does not match its persisted grant"); } } -function requirePersistedGrantFields( - streamGrant: Record<string, unknown>, - manifestStream: Record<string, unknown> -): void { - if (!(isRecord(manifestStream.selection) && manifestStream.selection.fields)) { - throw bindingError( - "grant_invalid", - `Persisted grant stream '${streamGrant.name}' does not support field-level selection` - ); - } - if (!isNonEmptyStringArray(streamGrant.fields)) { - throw bindingError( - "grant_invalid", - `Persisted grant stream '${streamGrant.name}' fields must be a non-empty array of field names` - ); - } - const schemaProperties = - isRecord(manifestStream.schema) && isRecord(manifestStream.schema.properties) - ? manifestStream.schema.properties - : {}; - const allowedFields = new Set(Object.keys(schemaProperties)); - const unknownFields = streamGrant.fields.filter((field) => !allowedFields.has(field)); - if (unknownFields.length) { - throw bindingError( - "grant_invalid", - `Unknown fields in persisted grant stream '${streamGrant.name}': ${unknownFields.join(", ")}` - ); - } +// The accepted resolved grant, not a mutable manifest, is authoritative. +export function requireGrantContractAgainstManifest(grant: DbRow = {}, _manifest: DbRow = {}): void { + requireClosedResolvedGrant(grant, "grant_invalid"); } -function requirePersistedGrantStream( - streamGrant: unknown, - manifest: DbRow, - manifestStreams: Record<string, unknown>[] -): void { - if (!(isRecord(streamGrant) && isNonEmptyString(streamGrant.name))) { - throw bindingError("grant_invalid", "grant.streams entries must include a non-empty name"); - } - const manifestStream = requirePersistedGrantManifestStream(streamGrant, manifest, manifestStreams); - if (streamGrant.time_range && !manifestStream.consent_time_field) { - throw bindingError( - "grant_invalid", - `Persisted grant stream '${streamGrant.name}' does not support time_range (no consent_time_field)` - ); - } - if (streamGrant.view) { - requirePersistedGrantView(streamGrant, manifestStream); - } else if (streamGrant.fields) { - requirePersistedGrantFields(streamGrant, manifestStream); - } +function resolvePendingRequestAgainstSnapshot(request: Partial<PendingRequest> = {}): StreamSelection[] { + const snapshot = readRetainedSourceDeclarationSnapshot(request); + return cloneJson(snapshot.resolved_streams); } -export function requireGrantContractAgainstManifest(grant: DbRow = {}, manifest: DbRow = {}): void { - if (!isNonEmptyString(grant.manifest_version)) { - throw bindingError("grant_invalid", "grant.manifest_version is required"); - } - if (typeof grant.access_mode !== "string" || !SUPPORTED_ACCESS_MODES.has(grant.access_mode)) { - throw bindingError("grant_invalid", 'grant.access_mode must be "single_use" or "continuous"'); - } - if (!isNonEmptyString(manifest.version) || grant.manifest_version !== manifest.version) { - throw bindingError( - "grant_invalid", - `grant.manifest_version '${grant.manifest_version}' does not match current manifest version '${manifest.version || "unknown"}'` - ); - } - if (!Array.isArray(grant.streams) || grant.streams.length === 0) { - throw bindingError("grant_invalid", "grant.streams must be a non-empty array"); - } - const manifestStreams = getManifestStreams(manifest); - for (const streamGrant of grant.streams) { - requirePersistedGrantStream(streamGrant, manifest, manifestStreams); - } +async function resolveSnapshotStreamsForApproval( + streams: StreamSelection[], + sourceBinding: SourceBinding, + storageBinding: StorageBinding, + subjectId: string, + opts: { acceptedRevisionFulfillment?: boolean } = {} +): Promise<ResolvedGrantStream[]> { + const eligibleStreams = await resolveEligibleInstanceIdsForApproval( + streams, + sourceBinding, + storageBinding, + subjectId, + opts + ); + return projectResolvedGrantStreams(eligibleStreams); } -function requirePendingRequestContractAgainstManifest( - request: Partial<PendingRequest> = {}, - manifest: DbRow = {} -): StreamSelection[] { - if (!isNonEmptyString(request.manifest_version)) { - throw bindingError("invalid_request", "pending request manifest_version is required"); - } - if (!isNonEmptyString(manifest.version) || request.manifest_version !== manifest.version) { - throw bindingError( - "invalid_request", - `Pending consent request manifest_version '${request.manifest_version}' does not match current manifest version '${manifest.version || "unknown"}'` - ); - } - return resolveGrantSelection(request.selection, manifest); +function resolvePendingRequestForApproval( + request: Partial<PendingRequest>, + sourceBinding: SourceBinding, + storageBinding: StorageBinding, + subjectId: string +): Promise<ResolvedGrantStream[]> { + const snapshot = readRetainedSourceDeclarationSnapshot(request); + return resolveSnapshotStreamsForApproval( + cloneJson(snapshot.resolved_streams), + sourceBinding, + storageBinding, + subjectId, + { acceptedRevisionFulfillment: Boolean(snapshot.accepted_revision_reference) } + ); } async function requirePendingRequestClientRegistration( @@ -2078,7 +2502,27 @@ export function requirePersistedGrantState(row: DbRow = {}): { if (!isRecord(parsedGrant)) { throw buildGrantInvalidError(); } - const grant: DbRow = parsedGrant; + if (isUnsupportedLegacyGrantShape(parsedGrant)) { + throw buildUnsupportedLegacyAuthorizationStateError(); + } + const grant = requireClosedResolvedGrant(parsedGrant, "grant_invalid"); + let tokenBinding: { clientId: unknown; expiresAt: unknown; grantId: unknown; subjectId: unknown } | undefined; + if (row.token_kind === "client") { + tokenBinding = { + clientId: row.client_id, + expiresAt: row.expires_at ?? null, + grantId: row.grant_id, + subjectId: row.subject_id, + }; + } else if (isNonEmptyString(row.token_grant_id)) { + tokenBinding = { + clientId: row.token_client_id, + expiresAt: row.token_expires_at ?? null, + grantId: row.token_grant_id, + subjectId: row.token_subject_id, + }; + } + requirePersistedGrantColumnBindings(grant, row, "grant_invalid", tokenBinding); const bindings = requireStructuredGrantBindings(grant, readPersistedGrantStorageBinding(row)); grant.source = describeSourceBinding(bindings.sourceBinding); return { @@ -2087,23 +2531,23 @@ export function requirePersistedGrantState(row: DbRow = {}): { storageBinding: bindings.storageBinding, }; } catch (cause: unknown) { + if (isAuthError(cause) && cause.code === "authorization_state.unsupported_legacy_shape") { + throw cause; + } const err = buildGrantInvalidError(); err.cause = cause; throw err; } } -export async function requireResolvedPersistedGrantState( +export function requireResolvedPersistedGrantState( row: DbRow = {}, - opts: { nativeManifest?: DbRow | null } = {} -): Promise<{ grant: DbRow; sourceBinding: SourceBinding; storageBinding: StorageBinding; manifest: DbRow }> { + _opts: { nativeManifest?: DbRow | null } = {} +): { grant: DbRow; sourceBinding: SourceBinding; storageBinding: StorageBinding } { try { const { grant, sourceBinding, storageBinding } = requirePersistedGrantState(row); - const manifest = await requireGrantManifestForBindings(sourceBinding, storageBinding, opts); - requireGrantContractAgainstManifest(grant, manifest); return { grant, - manifest, sourceBinding, storageBinding, }; @@ -2135,7 +2579,9 @@ const postgresPendingConsentStore: PendingConsentStore = { `SELECT device_code, user_code, params_json::text AS params_json, status, subject_id, grant_id, token_id, ai_training_consented, request_id, trace_id, scenario_id, created_at, expires_at, - approved_at, denied_at, interval_seconds, last_polled_at, approval_id + approved_at, denied_at, interval_seconds, last_polled_at, + approval_review_revision, approval_review_digest, approval_review_json::text AS approval_review_json, + approval_id FROM pending_consents WHERE approval_id = $1`, [approvalId] @@ -2145,12 +2591,14 @@ const postgresPendingConsentStore: PendingConsentStore = { `SELECT device_code, user_code, params_json::text AS params_json, status, subject_id, grant_id, token_id, ai_training_consented, request_id, trace_id, scenario_id, created_at, expires_at, - approved_at, denied_at, interval_seconds, last_polled_at, approval_id + approved_at, denied_at, interval_seconds, last_polled_at, + approval_review_revision, approval_review_digest, approval_review_json::text AS approval_review_json, + approval_id FROM pending_consents WHERE device_code = $1`, [deviceCode] ), - insert: ({ deviceCode, userCode, params, traceContext, createdAt, expiresAt, approvalId }) => + insert: ({ deviceCode, userCode, paramsJson, traceContext, createdAt, expiresAt, approvalId }) => pgExec( `INSERT INTO pending_consents( device_code, user_code, params_json, status, @@ -2159,7 +2607,7 @@ const postgresPendingConsentStore: PendingConsentStore = { [ deviceCode, userCode, - JSON.stringify(params), + paramsJson, traceContext.request_id, traceContext.trace_id, traceContext.scenario_id || null, @@ -2180,13 +2628,25 @@ const postgresPendingConsentStore: PendingConsentStore = { WHERE device_code = $6`, [subjectId, grantId, tokenId, aiTrainingConsented ? true : null, approvedAt, deviceCode] ), - markDenied: ({ deviceCode, deniedAt }) => - pgExec( - `UPDATE pending_consents - SET status = 'denied', denied_at = $1 - WHERE device_code = $2 AND status = 'pending'`, - [deniedAt, deviceCode] - ), + markDeniedAtomically: ({ deviceCode, deniedAt, event, faultHook }) => + withPostgresTransaction(async (client) => { + const result = await client.query( + `UPDATE pending_consents + SET status = 'denied', denied_at = $1 + WHERE device_code = $2 AND status = 'pending' + RETURNING device_code`, + [deniedAt, deviceCode] + ); + if (result.rowCount !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + await faultHook?.("after_cas_before_event"); + await postgresEmitSpineEventInTransaction(client, event as SpineEventInput); + await faultHook?.("after_event_before_commit"); + return { changes: 1 }; + }), markExpired: ({ deviceCode }) => pgExec("UPDATE pending_consents SET status = 'expired' WHERE device_code = $1 AND status = 'pending'", [ deviceCode, @@ -2200,11 +2660,11 @@ const sqlitePendingConsentStore: PendingConsentStore = { getOne<PendingConsentRow>(referenceQueries.authPendingConsentsGetByApprovalId, [approvalId]), getByDeviceCode: (deviceCode) => getOne<PendingConsentRow>(referenceQueries.authPendingConsentsGetByDeviceCode, [deviceCode]), - insert: ({ deviceCode, userCode, params, traceContext, createdAt, expiresAt, approvalId }) => + insert: ({ deviceCode, userCode, paramsJson, traceContext, createdAt, expiresAt, approvalId }) => exec(referenceQueries.authPendingConsentsInsert, [ deviceCode, userCode, - JSON.stringify(params), + paramsJson, traceContext.request_id, traceContext.trace_id, traceContext.scenario_id || null, @@ -2221,8 +2681,19 @@ const sqlitePendingConsentStore: PendingConsentStore = { approvedAt, deviceCode, ]), - markDenied: ({ deviceCode, deniedAt }) => - exec(referenceQueries.authPendingConsentsMarkDenied, [deniedAt, deviceCode]), + markDeniedAtomically: ({ deviceCode, deniedAt, event, faultHook }) => + writeTransaction(() => { + const result = exec(referenceQueries.authPendingConsentsMarkDenied, [deniedAt, deviceCode]); + if (result.changes !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + faultHook?.("after_cas_before_event"); + emitRawSpineEvent(event as SpineEventInput, getDb()); + faultHook?.("after_event_before_commit"); + return result; + }), markExpired: ({ deviceCode }) => exec(referenceQueries.authPendingConsentsMarkExpired, [deviceCode]), updateLastPolled: ({ deviceCode, polledAt }) => { const query = requireMutationQuery( @@ -2237,7 +2708,145 @@ function getPendingConsentStore() { return isPostgresStorageBackend() ? postgresPendingConsentStore : sqlitePendingConsentStore; } +function buildOwnerDeviceUnavailableError(row: OwnerDeviceAuthRow): AuthError { + return attachOwnerDeviceTraceContext( + Object.assign(new Error("Owner device authorization is not available"), { + code: "not_found", + }), + row + ); +} + +function buildOwnerDeviceApprovalConflictError(row: OwnerDeviceAuthRow): AuthError { + return attachOwnerDeviceTraceContext( + Object.assign(new Error("Pending consent approval conflict"), { + code: "approval_conflict", + }), + row + ); +} + +function requireOwnerDeviceApprovedSubject(row: OwnerDeviceAuthRow, subjectId: string): void { + if (row.subject_id === subjectId) { + return; + } + throw buildOwnerDeviceUnavailableError(row); +} + +function requireOwnerDevicePendingForApproval(row: OwnerDeviceAuthRow, input: OwnerDeviceApprovalInput): void { + if ( + row.client_id !== input.clientId || + input.clientId !== input.pendingSnapshot.client_id || + row.user_code !== input.pendingSnapshot.user_code || + row.approval_id !== input.pendingSnapshot.approval_id + ) { + throw buildOwnerDeviceUnavailableError(row); + } +} + +function buildOwnerDeviceExpiredError(row: OwnerDeviceAuthRow): AuthError { + return attachOwnerDeviceTraceContext( + Object.assign(new Error("Owner device authorization has expired"), { + code: "not_found", + }), + row + ); +} + +function isOwnerDeviceExpiredError(err: unknown): err is AuthError { + return isAuthError(err) && err.code === "not_found" && err.message === "Owner device authorization has expired"; +} + +function requireDynamicClientSubject(registeredClient: RegisteredClient, subjectId: string): RegisteredClient { + const existingSubject = + registeredClient.registration_mode === "dynamic" ? registeredClient.metadata.issuer_subject_id || null : null; + if (existingSubject && existingSubject !== subjectId) { + const err: AuthError = new Error("Dynamic client is bound to a different owner subject"); + err.code = "forbidden"; + throw err; + } + return registeredClient; +} + const postgresOwnerDeviceAuthStore: OwnerDeviceAuthStore = { + approveAtomically: (input) => + withPostgresTransaction(async (client) => { + const existing = await client.query<OwnerDeviceAuthRow>( + `SELECT * + FROM owner_device_auth + WHERE device_code = $1 + FOR UPDATE`, + [input.deviceCode] + ); + const [row] = existing.rows; + if (!row) { + const err: AuthError = new Error("Unknown user code"); + err.code = "not_found"; + throw err; + } + if (row.status === "approved" && row.token_id) { + requireOwnerDeviceApprovedSubject(row, input.subjectId); + return row; + } + if (row.status === "denied") { + throw buildOwnerDeviceApprovalConflictError(row); + } + if (row.status !== "pending") { + throw buildOwnerDeviceUnavailableError(row); + } + requireOwnerDevicePendingForApproval(row, input); + if (isExpired(row)) { + throw buildOwnerDeviceExpiredError(row); + } + const clientRowResult = await client.query<RegisteredClientRow>( + `SELECT client_id, registration_mode, token_endpoint_auth_method, + client_secret, metadata_json::text AS metadata_json, created_at, updated_at + FROM oauth_clients + WHERE client_id = $1 + FOR UPDATE`, + [input.clientId] + ); + const registeredClient = mapRegisteredClientRow(clientRowResult.rows[0] ?? null); + if (!registeredClient) { + throw ownerDeviceExchangeError(row, "invalid_client", `Unknown client_id: ${input.clientId}`); + } + requireDynamicClientSubject(registeredClient, input.subjectId); + if (registeredClient.registration_mode === "dynamic" && !registeredClient.metadata.issuer_subject_id) { + await client.query( + `UPDATE oauth_clients + SET metadata_json = $2::jsonb, + updated_at = $3 + WHERE client_id = $1`, + [ + registeredClient.client_id, + JSON.stringify({ ...registeredClient.metadata, issuer_subject_id: input.subjectId }), + nowIso(), + ] + ); + } + input.faultHook?.("before_token_insert"); + await client.query( + `INSERT INTO tokens(token_id, grant_id, subject_id, client_id, token_kind, expires_at) + VALUES($1, NULL, $2, $3, 'owner', $4)`, + [input.tokenId, input.subjectId, input.clientId, input.expiresAt] + ); + input.faultHook?.("after_token_insert"); + await postgresEmitSpineEventInTransaction(client, input.consentApprovedEvent as SpineEventInput); + await postgresEmitSpineEventInTransaction(client, input.tokenIssuedEvent as SpineEventInput); + const approved = await client.query<OwnerDeviceAuthRow>( + `UPDATE owner_device_auth + SET status = 'approved', + subject_id = $2, + token_id = $3, + approved_at = $4 + WHERE device_code = $1 + AND status = 'pending' + RETURNING *`, + [input.deviceCode, input.subjectId, input.tokenId, nowIso()] + ); + const [approvedRow] = approved.rows; + return approvedRow as OwnerDeviceAuthRow; + }), getByApprovalId: (approvalId) => pgOne<OwnerDeviceAuthRow>( `SELECT * @@ -2299,13 +2908,25 @@ const postgresOwnerDeviceAuthStore: OwnerDeviceAuthStore = { WHERE device_code = $4`, [subjectId, tokenId, approvedAt, deviceCode] ), - markDenied: ({ deviceCode, deniedAt }) => - pgExec( - `UPDATE owner_device_auth - SET status = 'denied', denied_at = $1 - WHERE device_code = $2 AND status = 'pending'`, - [deniedAt, deviceCode] - ), + markDeniedAtomically: ({ deviceCode, deniedAt, event, faultHook }) => + withPostgresTransaction(async (client) => { + const result = await client.query( + `UPDATE owner_device_auth + SET status = 'denied', denied_at = $1 + WHERE device_code = $2 AND status = 'pending' + RETURNING device_code`, + [deniedAt, deviceCode] + ); + if (result.rowCount !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + await faultHook?.("after_cas_before_event"); + await postgresEmitSpineEventInTransaction(client, event as SpineEventInput); + await faultHook?.("after_event_before_commit"); + return { changes: 1 }; + }), markExpired: ({ deviceCode }) => pgExec("UPDATE owner_device_auth SET status = 'expired' WHERE device_code = $1 AND status = 'pending'", [ deviceCode, @@ -2315,6 +2936,64 @@ const postgresOwnerDeviceAuthStore: OwnerDeviceAuthStore = { }; const sqliteOwnerDeviceAuthStore: OwnerDeviceAuthStore = { + approveAtomically: (input) => + transaction(() => { + const row = getOne<OwnerDeviceAuthRow>(referenceQueries.authOwnerDeviceAuthGetByDeviceCode, [input.deviceCode]); + if (!row) { + const err: AuthError = new Error("Unknown user code"); + err.code = "not_found"; + throw err; + } + if (row.status === "approved" && row.token_id) { + requireOwnerDeviceApprovedSubject(row, input.subjectId); + return row; + } + if (row.status === "denied") { + throw buildOwnerDeviceApprovalConflictError(row); + } + if (row.status !== "pending") { + throw buildOwnerDeviceUnavailableError(row); + } + requireOwnerDevicePendingForApproval(row, input); + if (isExpired(row)) { + throw buildOwnerDeviceExpiredError(row); + } + const registeredClient = mapRegisteredClientRow( + getOne<RegisteredClientRow>(referenceQueries.authOauthClientsGetByClientId, [input.clientId]) + ); + if (!registeredClient) { + throw ownerDeviceExchangeError(row, "invalid_client", `Unknown client_id: ${input.clientId}`); + } + requireDynamicClientSubject(registeredClient, input.subjectId); + if (registeredClient.registration_mode === "dynamic" && !registeredClient.metadata.issuer_subject_id) { + exec(referenceQueries.authOauthClientsUpsert, [ + registeredClient.client_id, + registeredClient.registration_mode, + registeredClient.token_endpoint_auth_method, + registeredClient.client_secret, + JSON.stringify({ ...registeredClient.metadata, issuer_subject_id: input.subjectId }), + registeredClient.created_at || nowIso(), + nowIso(), + ]); + } + input.faultHook?.("before_token_insert"); + exec(referenceQueries.authTokensInsertOwner, [input.tokenId, input.subjectId, input.clientId, input.expiresAt]); + input.faultHook?.("after_token_insert"); + emitRawSpineEvent(input.consentApprovedEvent as SpineEventInput, getDb()); + emitRawSpineEvent(input.tokenIssuedEvent as SpineEventInput, getDb()); + exec(referenceQueries.authOwnerDeviceAuthMarkApproved, [ + input.subjectId, + input.tokenId, + nowIso(), + input.deviceCode, + ]); + return { + ...row, + status: "approved", + subject_id: input.subjectId, + token_id: input.tokenId, + }; + }), getByApprovalId: (approvalId) => getOne<OwnerDeviceAuthRow>(referenceQueries.authOwnerDeviceAuthGetByApprovalId, [approvalId]), getByDeviceCode: (deviceCode) => @@ -2347,8 +3026,19 @@ const sqliteOwnerDeviceAuthStore: OwnerDeviceAuthStore = { ]), markApproved: ({ deviceCode, subjectId, tokenId, approvedAt }) => exec(referenceQueries.authOwnerDeviceAuthMarkApproved, [subjectId, tokenId, approvedAt, deviceCode]), - markDenied: ({ deviceCode, deniedAt }) => - exec(referenceQueries.authOwnerDeviceAuthMarkDenied, [deniedAt, deviceCode]), + markDeniedAtomically: ({ deviceCode, deniedAt, event, faultHook }) => + writeTransaction(() => { + const result = exec(referenceQueries.authOwnerDeviceAuthMarkDenied, [deniedAt, deviceCode]); + if (result.changes !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + faultHook?.("after_cas_before_event"); + emitRawSpineEvent(event as SpineEventInput, getDb()); + faultHook?.("after_event_before_commit"); + return result; + }), markExpired: ({ deviceCode }) => exec(referenceQueries.authOwnerDeviceAuthMarkExpired, [deviceCode]), updateLastPolled: ({ deviceCode, polledAt }) => exec(referenceQueries.authOwnerDeviceAuthUpdateLastPolled, [polledAt, deviceCode]), @@ -2497,6 +3187,17 @@ const postgresConnectorCatalogStore: ConnectorCatalogStore = { WHERE connector_id = $1`, [connectorId] ), + listBySourceId: async (sourceId) => + ( + await postgresQuery<DbRow>( + `SELECT connector_id, manifest::text AS manifest + FROM connectors + WHERE manifest #>> '{source_declaration,source,id}' = $1 + ORDER BY connector_id ASC + LIMIT 2`, + [sourceId] + ) + ).rows, listIds: async () => ( await postgresQuery<GrantPackageMemberRow>( @@ -2516,6 +3217,16 @@ const postgresConnectorCatalogStore: ConnectorCatalogStore = { const sqliteConnectorCatalogStore: ConnectorCatalogStore = { getManifestById: (connectorId) => getOne(referenceQueries.authConnectorsGetManifestById, [connectorId]), + listBySourceId: (sourceId) => + getDb() + .prepare( + `SELECT connector_id, manifest + FROM connectors + WHERE json_extract(manifest, '$.source_declaration.source.id') = ? + ORDER BY connector_id ASC + LIMIT 2` + ) + .all<DbRow>(sourceId), // REVIEWED-BOUNDED: connectors table is O(registered providers); whole-table scan is acceptable. listIds: () => allowUnboundedReadAcknowledged(referenceQueries.authConnectorsListIds), upsert: ({ connectorId, manifestJson }) => exec(referenceQueries.authConnectorsUpsert, [connectorId, manifestJson]), @@ -2603,6 +3314,17 @@ async function getPendingConsentRow(deviceCode: string): Promise<PendingConsentR return await getPendingConsentStore().getByDeviceCode(deviceCode); } +function serializePendingConsentParams(params: PendingRequest | StagedBatchRequest): string { + if (isStagedBatchRequest(params)) { + for (const entry of params.entries) { + readRetainedSourceDeclarationSnapshot(asSingleEntryRequestSlice(params, entry)); + } + } else { + readRetainedSourceDeclarationSnapshot(params); + } + return JSON.stringify(cloneJson(params)); +} + async function createPendingConsent( deviceCode: string, userCode: string, @@ -2615,12 +3337,13 @@ async function createPendingConsent( // projections. Generated alongside the row so every public read surface // has a stable id without exposing the live device_code. const approvalId = generateId("appr"); + const paramsJson = serializePendingConsentParams(params); await getPendingConsentStore().insert({ approvalId, createdAt, deviceCode, expiresAt, - params, + paramsJson, traceContext, userCode, }); @@ -2633,30 +3356,830 @@ export async function getPendingConsentRowByApprovalId(approvalId: unknown): Pro return await getPendingConsentStore().getByApprovalId(approvalId); } -async function markPendingConsentApproved( - deviceCode: string, - { - subjectId, - grantId, - tokenId, - aiTrainingConsented, - }: { subjectId: string; grantId: string; tokenId: string; aiTrainingConsented: boolean | null | undefined } -): Promise<void> { - await getPendingConsentStore().markApproved({ - aiTrainingConsented, - approvedAt: nowIso(), - deviceCode, - grantId, - subjectId, - tokenId, - }); -} - -async function markPendingConsentDenied(deviceCode: string): Promise<void> { - await getPendingConsentStore().markDenied({ deniedAt: nowIso(), deviceCode }); +function buildConsentApprovedEventInput({ + deviceCode, + pending, + registeredClient, + request, + resolvedStreams, + sourceBinding, + subjectId, + traceContext, + grantId, +}: { + deviceCode: string; + pending: PendingConsentRow; + registeredClient: RegisteredClient; + request: PendingRequest; + resolvedStreams: ResolvedGrantStream[]; + sourceBinding: SourceBinding; + subjectId: string; + traceContext: TraceContext; + grantId: string; +}): AuthSpineEventInput { + return { + actor_id: subjectId, + actor_type: "subject", + client_id: registeredClient.client_id, + data: { + source: describeSourceBinding(sourceBinding), + ...buildResolvedSnapshotEvidence(request, resolvedStreams), + user_code: pending.user_code, + }, + event_type: "consent.approved", + grant_id: grantId, + object_id: deviceCode, + object_type: "pending_consent", + request_id: traceContext.request_id, + scenario_id: traceContext.scenario_id, + status: "succeeded", + subject_id: subjectId, + subject_type: "subject", + trace_id: traceContext.trace_id, + }; } -async function markPendingConsentExpired(deviceCode: string): Promise<void> { +function buildGrantIssuedEventInput({ + grant, + grantId, + registeredClient, + request, + resolvedStreams, + selection, + subjectId, + traceContext, +}: { + grant: GrantEnvelope; + grantId: string; + registeredClient: RegisteredClient; + request: PendingRequest; + resolvedStreams: ResolvedGrantStream[]; + selection: GrantSelection; + subjectId: string; + traceContext: TraceContext; +}): AuthSpineEventInput { + return { + actor_id: "pdpp_as", + actor_type: "authorization_server", + client_id: registeredClient.client_id, + data: { + access_mode: selection.access_mode, + purpose_code: selection.purpose_code, + retention: selection.retention ?? null, + source: describeGrantSource(grant), + ...buildResolvedSnapshotEvidence(request, resolvedStreams), + stream_names: resolvedStreams.map((stream) => stream.name), + }, + event_type: "grant.issued", + grant_id: grantId, + object_id: grantId, + object_type: "grant", + request_id: traceContext.request_id, + scenario_id: traceContext.scenario_id, + status: "succeeded", + subject_id: subjectId, + subject_type: "subject", + trace_id: traceContext.trace_id, + }; +} + +function buildTokenIssuedEventInput({ + clientId, + grant, + grantId, + subjectId, + tokenId, + traceContext, +}: { + clientId: string; + grant: GrantEnvelope; + grantId: string; + subjectId: string; + tokenId: string; + traceContext: TraceContext; +}): AuthSpineEventInput { + return { + actor_id: "pdpp_as", + actor_type: "authorization_server", + client_id: clientId, + data: { + issuance_path: "grant_approval", + source: describeGrantSource(grant), + token_kind: "client", + }, + event_type: "token.issued", + grant_id: grantId, + object_id: tokenId, + object_type: "token", + request_id: traceContext.request_id, + scenario_id: traceContext.scenario_id, + status: "succeeded", + subject_id: subjectId, + subject_type: "subject", + token_id: tokenId, + trace_id: traceContext.trace_id, + }; +} + +function canonicalApprovalReviewJson(value: unknown): string { + return JSON.stringify(value, (_key, candidate) => { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) { + return candidate; + } + const sorted: Record<string, unknown> = {}; + for (const key of Object.keys(candidate).sort()) { + sorted[key] = candidate[key]; + } + return sorted; + }); +} + +function normalizeApprovalReviewClientClaims(raw: unknown): { commitments: string[] } | null { + if (!isRecord(raw)) { + return null; + } + const commitments = Array.isArray(raw.commitments) + ? raw.commitments.filter((value): value is string => typeof value === "string" && value.trim() !== "") + : []; + return commitments.length > 0 ? { commitments } : null; +} + +function buildApprovalReviewArtifact(input: { + aiTrainingConsented: boolean | null; + client: unknown; + expiresAt: string | null; + request: PendingRequest; + resolvedStreams: ResolvedGrantStream[] | StreamSelection[]; + subjectId: string; +}): { artifactJson: string; digest: string; revision: string } { + const { request } = input; + const snapshot = readRetainedSourceDeclarationSnapshot(request); + const { selection } = request; + const artifact = { + access_mode: selection.access_mode, + ai_training_consented: input.aiTrainingConsented, + client: input.client, + client_claims: normalizeApprovalReviewClientClaims(selection.client_claims), + expires_at: input.expiresAt, + purpose_code: selection.purpose_code, + purpose_description: selection.purpose_description ?? null, + resolved_streams: input.resolvedStreams, + retention: selection.retention ?? null, + selection_preset: selection.selection_preset ?? null, + source: describeSourceBinding(request.source_binding), + source_declaration: buildApprovalReviewSourceDeclaration(snapshot), + subject: { id: input.subjectId }, + version: "reference.approval-review.v1", + }; + const artifactJson = canonicalApprovalReviewJson(artifact); + const digest = `sha256:${base64UrlSha256(artifactJson)}`; + return { artifactJson, digest, revision: `reference.approval-review.v1:${digest}` }; +} + +function buildApprovalReviewSourceDeclaration(snapshot: SourceDeclarationSnapshot): Record<string, unknown> { + return { + ...(snapshot.accepted_revision_reference + ? { accepted_revision_reference: snapshot.accepted_revision_reference } + : {}), + digest: `sha256:${base64UrlSha256(canonicalApprovalReviewJson(snapshot.declaration))}`, + ...(snapshot.publisher_attribution ? { publisher_attribution: snapshot.publisher_attribution } : {}), + ...(snapshot.resource_authority ? { resource_authority: snapshot.resource_authority } : {}), + version: snapshot.declaration_version, + }; +} + +function buildBatchApprovalReviewArtifact(input: { + approvedIndexes: number[]; + client: unknown; + entries: { + index: number; + request: PendingRequest; + resolvedStreams: ResolvedGrantStream[] | StreamSelection[]; + }[]; + expiresAt: string | null; + parentPackageId: string | null; + sourceNarrowing: Record<string, unknown>; + subjectId: string; +}): { artifactJson: string; digest: string; revision: string } { + const [firstEntry] = input.entries; + const artifact = { + access_mode: firstEntry ? firstEntry.request.selection.access_mode : null, + approved_source_indexes: input.approvedIndexes, + client: input.client, + expires_at: input.expiresAt, + parent_package_id: input.parentPackageId, + source_narrowing: input.sourceNarrowing, + sources: input.entries.map(({ index, request, resolvedStreams }) => { + const snapshot = readRetainedSourceDeclarationSnapshot(request); + return { + access_mode: request.selection.access_mode, + client_claims: normalizeApprovalReviewClientClaims(request.selection.client_claims), + index, + purpose_code: request.selection.purpose_code, + purpose_description: request.selection.purpose_description ?? null, + resolved_streams: resolvedStreams, + retention: request.selection.retention ?? null, + selection_preset: request.selection.selection_preset ?? null, + source: describeSourceBinding(request.source_binding), + source_declaration: buildApprovalReviewSourceDeclaration(snapshot), + }; + }), + subject: { id: input.subjectId }, + version: "reference.batch-approval-review.v1", + }; + const artifactJson = canonicalApprovalReviewJson(artifact); + const digest = `sha256:${base64UrlSha256(artifactJson)}`; + return { artifactJson, digest, revision: `reference.batch-approval-review.v1:${digest}` }; +} + +async function persistApprovalReviewArtifact(input: { + deviceCode: string; + artifactJson: string; + digest: string; + revision: string; +}): Promise<void> { + if (isPostgresStorageBackend()) { + const result = await postgresQuery( + `UPDATE pending_consents + SET approval_review_revision = $2, + approval_review_digest = $3, + approval_review_json = $4::jsonb + WHERE device_code = $1 + AND status = 'pending'`, + [input.deviceCode, input.revision, input.digest, input.artifactJson] + ); + if (result.rowCount !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + return; + } + const result = execDynamicSqlAcknowledged( + `UPDATE pending_consents + SET approval_review_revision = ?, + approval_review_digest = ?, + approval_review_json = ? + WHERE device_code = ? + AND status = 'pending'`, + [input.revision, input.digest, input.artifactJson, input.deviceCode] + ); + if (result.changes !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } +} + +function requireMatchingApprovalReview( + row: PendingConsentRow, + revision: unknown +): { + aiTrainingConsented: boolean | null; + expiresAt: string | null; + resolvedStreams: ResolvedGrantStream[]; + subjectId: string; +} { + if (!isNonEmptyString(revision)) { + throw bindingError("invalid_request", "approval_review_revision is required"); + } + if (!(isNonEmptyString(row.approval_review_revision) && isNonEmptyString(row.approval_review_json))) { + throw bindingError("invalid_request", "Pending consent must be reviewed again before approval"); + } + if (revision !== row.approval_review_revision) { + throw bindingError("invalid_request", "Pending consent review is stale"); + } + const parsed = parsePersistedApprovalReviewRow(row); + if (!(isRecord(parsed.subject) && isNonEmptyString(parsed.subject.id))) { + throw bindingError("invalid_request", "Pending consent review subject is malformed; review the request again"); + } + return { + aiTrainingConsented: typeof parsed.ai_training_consented === "boolean" ? parsed.ai_training_consented : null, + expiresAt: typeof parsed.expires_at === "string" ? parsed.expires_at : null, + resolvedStreams: parsed.resolved_streams as ResolvedGrantStream[], + subjectId: parsed.subject.id, + }; +} + +function persistedBatchReviewOptions(row: PendingConsentRow): ApproveStagedGrantBatchOptions { + if (!isNonEmptyString(row.approval_review_json)) { + throw bindingError("invalid_request", "Pending consent must be reviewed again before approval"); + } + const parsed = parsePersistedApprovalReviewRow(row); + requireBatchApprovalReviewArtifact(parsed); + return { + approvedSourceIndexes: parsed.approved_source_indexes.map((index) => Number(index)), + reviewExpiresAt: typeof parsed.expires_at === "string" ? parsed.expires_at : null, + sourceNarrowing: isRecord(parsed.source_narrowing) ? parsed.source_narrowing : {}, + }; +} + +function parsePersistedApprovalReview(raw: string): Record<string, unknown> & { subject?: { id?: unknown } } { + try { + const parsed = JSON.parse(raw); + if (!isRecord(parsed)) { + throw new Error("approval review must be an object"); + } + requirePersistedApprovalReviewShape(parsed); + return parsed as Record<string, unknown> & { subject?: { id?: unknown } }; + } catch (err: unknown) { + if (isAuthError(err) && err.code) { + throw err; + } + throw bindingError("invalid_request", "Pending consent review is malformed; review the request again"); + } +} + +function parsePersistedApprovalReviewRow( + row: PendingConsentRow +): Record<string, unknown> & { subject?: { id?: unknown } } { + if (!(isNonEmptyString(row.approval_review_json) && isNonEmptyString(row.approval_review_digest))) { + throw bindingError("invalid_request", "Pending consent must be reviewed again before approval"); + } + const parsed = parsePersistedApprovalReview(row.approval_review_json); + const artifactJson = canonicalApprovalReviewJson(parsed); + const digest = `sha256:${base64UrlSha256(artifactJson)}`; + const version = typeof parsed.version === "string" ? parsed.version : null; + if (digest !== row.approval_review_digest || !version || `${version}:${digest}` !== row.approval_review_revision) { + throw bindingError("invalid_request", "Pending consent review is stale"); + } + const validation = validateResponse("reviewConsent", { + body: { + approval_review: parsed, + approval_review_revision: row.approval_review_revision, + batch: version === "reference.batch-approval-review.v1", + request_uri: "urn:pdpp:pending-consent:review-validation", + }, + status: 200, + }); + if (validation.ok !== true) { + throw bindingError("invalid_request", "Pending consent review is malformed; review the request again"); + } + requireReviewSourceTrustMatchesSourceKind(parsed); + return parsed; +} + +function requireReviewSourceTrustMatchesSourceKind(review: Record<string, unknown>): void { + const sources = review.version === "reference.batch-approval-review.v1" ? review.sources : [review]; + if (!Array.isArray(sources)) { + throw bindingError("invalid_request", "Pending consent review is malformed; review the request again"); + } + for (const item of sources) { + const source = isRecord(item) && isRecord(item.source) ? item.source : null; + const declaration = isRecord(item) && isRecord(item.source_declaration) ? item.source_declaration : null; + if (!(source && declaration)) { + throw bindingError("invalid_request", "Pending consent review is malformed; review the request again"); + } + const hasTrust = + declaration.accepted_revision_reference !== undefined || + declaration.publisher_attribution !== undefined || + declaration.resource_authority !== undefined; + if ((source.kind === "provider_native") !== hasTrust) { + throw bindingError( + "invalid_request", + "Pending consent review source authority is malformed; review the request again" + ); + } + } +} + +function hasPersistedApprovalReview(row: PendingConsentRow): boolean { + return [row.approval_review_json, row.approval_review_digest, row.approval_review_revision].some( + (value) => value !== null && value !== undefined + ); +} + +async function readValidatedPersistedApprovalReview(deviceCode: string): Promise<{ + artifact: Record<string, unknown> & { subject?: { id?: unknown } }; + artifactJson: string; + digest: string; + revision: string; +}> { + const row = await getPendingConsentRow(deviceCode); + if (row?.status !== "pending") { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + const artifact = parsePersistedApprovalReviewRow(row); + if ( + !( + isNonEmptyString(row.approval_review_json) && + isNonEmptyString(row.approval_review_digest) && + isNonEmptyString(row.approval_review_revision) + ) + ) { + throw bindingError("invalid_request", "Pending consent must be reviewed again before approval"); + } + return { + artifact, + artifactJson: row.approval_review_json, + digest: row.approval_review_digest, + revision: row.approval_review_revision, + }; +} + +function requirePersistedApprovalReviewShape(parsed: Record<string, unknown>): void { + const { version } = parsed; + if (version === "reference.approval-review.v1") { + if ( + !( + isRecord(parsed.subject) && + isNonEmptyString(parsed.subject.id) && + Array.isArray(parsed.resolved_streams) && + (typeof parsed.ai_training_consented === "boolean" || parsed.ai_training_consented === null) + ) + ) { + throw bindingError("invalid_request", "Pending consent review is malformed; review the request again"); + } + return; + } + if (version === "reference.batch-approval-review.v1") { + requireBatchApprovalReviewArtifact(parsed); + return; + } + throw bindingError("invalid_request", "Pending consent review is malformed; review the request again"); +} + +function requireBatchApprovalReviewArtifact(parsed: Record<string, unknown>): asserts parsed is Record< + string, + unknown +> & { + approved_source_indexes: unknown[]; + expires_at?: unknown; + source_narrowing?: unknown; + sources: unknown[]; +} { + if ( + parsed.version !== "reference.batch-approval-review.v1" || + !Array.isArray(parsed.approved_source_indexes) || + !(isRecord(parsed.subject) && isNonEmptyString(parsed.subject.id)) || + !Array.isArray(parsed.sources) + ) { + throw bindingError("invalid_request", "Pending batch review is malformed; review the request again"); + } + for (const index of parsed.approved_source_indexes) { + if (!Number.isInteger(Number(index))) { + throw bindingError( + "invalid_request", + "Pending batch review has invalid source indexes; review the request again" + ); + } + } + for (const source of parsed.sources) { + if (!(isRecord(source) && Number.isInteger(Number(source.index)) && Array.isArray(source.resolved_streams))) { + throw bindingError("invalid_request", "Pending batch review has invalid source facts; review the request again"); + } + } + if (parsed.source_narrowing !== undefined && !isRecord(parsed.source_narrowing)) { + throw bindingError("invalid_request", "Pending batch review has invalid narrowing facts; review the request again"); + } +} + +function requireNumericObjectKeys(input: Record<string, unknown>, param: string): void { + for (const key of Object.keys(input)) { + if (!CANONICAL_NON_NEGATIVE_INTEGER_KEY_RE.test(key)) { + const err: AuthError = new Error(`${param} key '${key}' must be a staged source index`); + err.code = "invalid_request"; + err.param = param; + throw err; + } + } +} + +interface ReviewedInstanceCheck { + allowConfiguredFulfillmentDefault: boolean; + connectorId: string; + connectorInstanceId: string; + subjectId: string; +} + +function reviewedInstanceChecksForGrant(input: { + acceptedRevisionFulfillment?: boolean; + resolvedStreams: ResolvedGrantStream[]; + sourceBinding: SourceBinding; + storageBinding: StorageBinding; + subjectId: string; +}): ReviewedInstanceCheck[] { + const configuredFulfillment = input.acceptedRevisionFulfillment + ? isConfiguredStorageFulfillment(input.storageBinding) + : isConfiguredFulfillment(input.sourceBinding, input.storageBinding); + const configuredDefaultInstanceId = configuredFulfillment + ? makeDefaultAccountConnectorInstanceId(input.subjectId, input.storageBinding.connector_id) + : null; + const seen = new Set<string>(); + const checks: ReviewedInstanceCheck[] = []; + for (const stream of input.resolvedStreams) { + for (const connectorInstanceId of stream.instance_ids) { + const key = `${input.storageBinding.connector_id}\0${connectorInstanceId}\0${input.subjectId}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + checks.push({ + allowConfiguredFulfillmentDefault: connectorInstanceId === configuredDefaultInstanceId, + connectorId: input.storageBinding.connector_id, + connectorInstanceId, + subjectId: input.subjectId, + }); + } + } + return checks; +} + +function reviewedInstanceChangedError(connectorInstanceId: string): AuthError { + const err: AuthError = new Error( + `Reviewed source instance '${connectorInstanceId}' is no longer eligible; review the request again` + ); + err.code = "invalid_request"; + return err; +} + +function coerceAiTrainingConsent(value: unknown): boolean | null { + if (value === undefined || value === null || value === "") { + return null; + } + if (typeof value === "boolean") { + return value; + } + if (value === "true" || value === "1" || value === "on") { + return true; + } + if (value === "false" || value === "0" || value === "off") { + return false; + } + const err = bindingError("invalid_request", "ai_training_consented must be a boolean"); + err.param = "ai_training_consented"; + throw err; +} + +function requireReviewedAiTrainingConsent(selection: GrantSelection, aiTrainingConsented: boolean | null): void { + if (selection.purpose_code === "https://pdpp.dev/purpose/ai_training" && aiTrainingConsented !== true) { + const err: AuthError = new Error("Explicit affirmative consent required for ai_training purpose"); + err.code = "invalid_request"; + err.param = "ai_training_consented"; + throw err; + } +} + +function requireSqliteReviewedInstancesActive(checks: ReviewedInstanceCheck[]): void { + for (const check of checks) { + const row = getOne(referenceQueries.authConnectorInstancesGetReviewedActive, [ + check.connectorInstanceId, + check.connectorId, + check.subjectId, + ]); + if (!row) { + if (check.allowConfiguredFulfillmentDefault) { + continue; + } + throw reviewedInstanceChangedError(check.connectorInstanceId); + } + } +} + +async function requirePostgresReviewedInstancesActive( + client: PostgresTransactionClient, + checks: ReviewedInstanceCheck[] +): Promise<void> { + for (const check of checks) { + // biome-ignore lint/performance/noAwaitInLoops: These row locks are intentionally acquired inside the approval transaction. + const result = await client.query( + `SELECT connector_instance_id + FROM connector_instances + WHERE connector_instance_id = $1 + AND connector_id = $2 + AND owner_subject_id = $3 + AND status = 'active' + FOR UPDATE`, + [check.connectorInstanceId, check.connectorId, check.subjectId] + ); + if (result.rowCount !== 1) { + if (check.allowConfiguredFulfillmentDefault) { + continue; + } + throw reviewedInstanceChangedError(check.connectorInstanceId); + } + } +} + +function requireSqliteParentPackageStillEligible(input: { + clientId: string; + parentPackageId: string | null; + subjectId: string; +}): void { + if (!input.parentPackageId) { + return; + } + const row = getDb() + .prepare( + `SELECT package_id + FROM grant_packages + WHERE package_id = ? + AND client_id = ? + AND subject_id = ? + AND status = 'active'` + ) + .get(input.parentPackageId, input.clientId, input.subjectId); + if (!row) { + throw parentPackageChangedError(input.parentPackageId); + } +} + +async function requirePostgresParentPackageStillEligible( + client: PostgresTransactionClient, + input: { + clientId: string; + parentPackageId: string | null; + subjectId: string; + } +): Promise<void> { + if (!input.parentPackageId) { + return; + } + const result = await client.query( + `SELECT package_id + FROM grant_packages + WHERE package_id = $1 + AND client_id = $2 + AND subject_id = $3 + AND status = 'active' + FOR UPDATE`, + [input.parentPackageId, input.clientId, input.subjectId] + ); + if (result.rowCount !== 1) { + throw parentPackageChangedError(input.parentPackageId); + } +} + +function parentPackageChangedError(packageId: string): AuthError { + const err: AuthError = new Error(`parent_package_id ${packageId} is no longer eligible; review the request again`); + err.code = "invalid_request"; + err.param = "parent_package_id"; + return err; +} + +async function persistApprovedSingleGrantAtomically({ + accessMode, + aiTrainingConsented, + clientId, + consentApprovedEvent, + deviceCode, + expiresAt, + grantId, + grantIssuedEvent, + grantJson, + issuedAt, + persistedStorageBinding, + subjectId, + tokenIssuedEvent, + traceContext, + reviewedRevision, + reviewedInstanceChecks, +}: { + accessMode: string; + aiTrainingConsented: boolean | null | undefined; + clientId: string; + consentApprovedEvent: AuthSpineEventInput; + deviceCode: string; + expiresAt: string | null; + grantId: string; + grantIssuedEvent: AuthSpineEventInput; + grantJson: string; + issuedAt: string; + persistedStorageBinding: StorageBinding | null; + subjectId: string; + tokenIssuedEvent: (tokenId: string) => AuthSpineEventInput; + traceContext: TraceContext; + reviewedRevision: string; + reviewedInstanceChecks: ReviewedInstanceCheck[]; +}): Promise<string> { + const storageBindingJson = serializeStorageBinding(persistedStorageBinding); + + if (isPostgresStorageBackend()) { + return await withPostgresTransaction(async (client) => { + const claim = await client.query( + `UPDATE pending_consents + SET status = 'approving', subject_id = $2 + WHERE device_code = $1 + AND status = 'pending' + AND approval_review_revision = $3 + RETURNING device_code`, + [deviceCode, subjectId, reviewedRevision] + ); + if (claim.rowCount !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + await requirePostgresReviewedInstancesActive(client, reviewedInstanceChecks); + + await client.query( + `INSERT INTO grants( + grant_id, subject_id, client_id, storage_binding_json, grant_json, + access_mode, issued_at, expires_at, trace_id, scenario_id + ) VALUES($1, $2, $3, $4::jsonb, $5::jsonb, $6, $7, $8, $9, $10)`, + [ + grantId, + subjectId, + clientId, + storageBindingJson, + grantJson, + accessMode, + issuedAt, + expiresAt, + traceContext.trace_id, + traceContext.scenario_id ?? null, + ] + ); + const { tokenId } = await insertPostgresGrantToken(client, { clientId, expiresAt, grantId, subjectId }); + await postgresEmitSpineEventInTransaction(client, consentApprovedEvent as SpineEventInput); + await postgresEmitSpineEventInTransaction(client, grantIssuedEvent as SpineEventInput); + await postgresEmitSpineEventInTransaction(client, tokenIssuedEvent(tokenId) as SpineEventInput); + const finalApproval = await client.query( + `UPDATE pending_consents + SET status = 'approved', + subject_id = $2, + grant_id = $3, + token_id = $4, + ai_training_consented = $5, + approved_at = $6 + WHERE device_code = $1 + AND status = 'approving'`, + [deviceCode, subjectId, grantId, tokenId, aiTrainingConsented ?? null, nowIso()] + ); + if (finalApproval.rowCount !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + return tokenId; + }); + } + + return transaction(() => { + const db = getDb(); + const claim = db + .prepare( + `UPDATE pending_consents + SET status = 'approving', subject_id = ? + WHERE device_code = ? + AND status = 'pending' + AND approval_review_revision = ?` + ) + .run(subjectId, deviceCode, reviewedRevision); + if (claim.changes !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + requireSqliteReviewedInstancesActive(reviewedInstanceChecks); + exec(referenceQueries.authGrantsInsert, [ + grantId, + subjectId, + clientId, + storageBindingJson, + grantJson, + accessMode, + issuedAt, + expiresAt, + traceContext.trace_id, + traceContext.scenario_id ?? null, + ]); + const { tokenId } = insertSqliteGrantTokenInCurrentTransaction({ clientId, expiresAt, grantId, subjectId }); + emitRawSpineEvent(consentApprovedEvent as SpineEventInput, db); + emitRawSpineEvent(grantIssuedEvent as SpineEventInput, db); + emitRawSpineEvent(tokenIssuedEvent(tokenId) as SpineEventInput, db); + const finalApproval = db + .prepare( + `UPDATE pending_consents + SET status = 'approved', + subject_id = ?, + grant_id = ?, + token_id = ?, + ai_training_consented = ?, + approved_at = ? + WHERE device_code = ? + AND status = 'approving'` + ) + .run( + subjectId, + grantId, + tokenId, + aiTrainingConsented === undefined ? null : Number(aiTrainingConsented), + nowIso(), + deviceCode + ); + if (finalApproval.changes !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + return tokenId; + }); +} + +async function markPendingConsentExpired(deviceCode: string): Promise<void> { await getPendingConsentStore().markExpired({ deviceCode }); } @@ -2722,22 +4245,6 @@ export async function getOwnerDeviceAuthRowByApprovalId(approvalId: unknown): Pr return await getOwnerDeviceAuthStore().getByApprovalId(approvalId); } -async function markOwnerDeviceAuthApproved( - deviceCode: string, - { subjectId, tokenId }: { subjectId: string; tokenId: string } -): Promise<void> { - await getOwnerDeviceAuthStore().markApproved({ - approvedAt: nowIso(), - deviceCode, - subjectId, - tokenId, - }); -} - -async function markOwnerDeviceAuthDenied(deviceCode: string): Promise<void> { - await getOwnerDeviceAuthStore().markDenied({ deniedAt: nowIso(), deviceCode }); -} - async function markOwnerDeviceAuthExpired(deviceCode: string): Promise<void> { await getOwnerDeviceAuthStore().markExpired({ deviceCode }); } @@ -3046,38 +4553,6 @@ export async function deleteCimdDocument( } } -async function bindDynamicClientToApprovingOwner( - registeredClient: RegisteredClient, - subjectId: string -): Promise<RegisteredClient> { - if (!(registeredClient.client_id && subjectId)) { - return registeredClient; - } - if (registeredClient.registration_mode !== "dynamic") { - return registeredClient; - } - const existingSubject = registeredClient.metadata.issuer_subject_id || null; - if (existingSubject) { - if (existingSubject !== subjectId) { - const err: AuthError = new Error("Dynamic client is bound to a different owner subject"); - err.code = "forbidden"; - throw err; - } - return registeredClient; - } - - await upsertRegisteredClient({ - clientId: registeredClient.client_id, - clientSecret: registeredClient.client_secret || null, - metadata: { - ...registeredClient.metadata, - issuer_subject_id: subjectId, - }, - registrationMode: registeredClient.registration_mode, - }); - return (await getRegisteredClient(registeredClient.client_id)) || registeredClient; -} - /** * Operator-scoped listing of dynamic clients the dashboard registered on * behalf of a particular owner-session subject. Backs `GET /_ref/clients?owner=true`. @@ -3768,6 +5243,26 @@ function normalizeConnectorManifestForStorage(manifest: Record<string, unknown>) storedManifest.manifest_uri = originalConnectorId; } } + if (!isRecord(storedManifest.source_declaration) && isNonEmptyString(manifest.connector_id)) { + // `connector_id` is the local storage key after canonicalization. When a + // legacy manifest carried a URL-shaped identity in `manifest_uri`, retain + // that explicit URI as the SourceDeclaration identity instead of asking + // the projection to materialize a non-URI storage key (for example the + // local `codex` catalog entry). + const sourceId = isNonEmptyString(manifest.manifest_uri) + ? manifest.manifest_uri.trim() + : manifest.connector_id.trim(); + storedManifest.source_declaration = snapshotContentAddressedSourceDeclarationFromLegacyConnectorManifest( + storedManifest, + { + connectorImplementationId: isNonEmptyString(storedManifest.manifest_uri) + ? storedManifest.manifest_uri + : sourceId, + publisherId: LEGACY_CONNECTOR_PROJECTION_PUBLISHER_ID, + sourceId, + } + ); + } return { connectorId, storedManifest }; } @@ -4046,6 +5541,24 @@ function requiresStagedGrantBatch(input: Record<string, unknown>): boolean { return Array.isArray(input.authorization_details) && (input.authorization_details.length > 1 || hasParentPackageId); } +async function requireInitiationRegisteredClient( + request: PendingRequest, + opts: InitiateGrantOptions, + traceContext: TraceContext +): Promise<RegisteredClient> { + const registeredClient = await resolveOAuthClient(request.client.client_id, { + ...opts, + requestId: traceContext.request_id, + traceId: traceContext.trace_id, + }); + if (!registeredClient) { + const err: AuthError = new Error(`Unknown client_id: ${request.client.client_id}`); + err.code = "invalid_client"; + throw err; + } + return registeredClient; +} + /** * Persist a pending grant-approval request and expose it as a PAR-backed consent request. * Returns the staged request URI plus the consent URL for the primary request/approval flow. @@ -4054,10 +5567,11 @@ export async function initiateGrant( input: Record<string, unknown>, opts: InitiateGrantOptions = {} ): Promise<Record<string, unknown>> { + const preparedOpts = await prepareInitiateGrantOptions(opts); if (requiresStagedGrantBatch(input)) { - return initiateStagedGrantBatch(input, opts); + return initiateStagedGrantBatch(input, preparedOpts); } - const normalized = normalizePendingGrantRequest(input, opts); + const normalized = await normalizePendingGrantRequest(input, preparedOpts); requireStructuredPendingRequestShape(normalized); const traceContext = getRequestTraceContext( normalized, @@ -4067,21 +5581,13 @@ export async function initiateGrant( const sourceBinding = getRequestSourceBinding(normalized); try { - const registeredClient = await resolveOAuthClient(normalized.client.client_id, { - ...opts, - requestId: traceContext.request_id, - traceId: traceContext.trace_id, - }); - if (!registeredClient) { - const err: AuthError = new Error(`Unknown client_id: ${normalized.client.client_id}`); - err.code = "invalid_client"; - throw err; - } + const registeredClient = await requireInitiationRegisteredClient(normalized, preparedOpts, traceContext); applyRegisteredClientToPendingRequestClient(normalized, registeredClient); - const storageBinding = getRequestStorageBinding(normalized); - const manifest = await requireGrantManifestForBindings(sourceBinding, storageBinding, opts); - resolveGrantSelection(normalized.selection, manifest); - normalized.manifest_version = requireManifestVersion(manifest); + const { sourceBinding: validatedSourceBinding, storageBinding } = + requireStructuredPendingRequestBindings(normalized); + const manifest = await requireGrantManifestForBindings(validatedSourceBinding, storageBinding, preparedOpts); + await retainSourceDeclarationSnapshot(normalized, validatedSourceBinding, storageBinding, manifest, preparedOpts); + resolvePendingRequestAgainstSnapshot(normalized); const deviceCode = generateId("dc"); const userCode = randomBytes(3).toString("hex").toUpperCase(); @@ -4093,8 +5599,9 @@ export async function initiateGrant( const requestEventData = { access_mode: normalized.selection.access_mode || null, purpose_code: normalized.selection.purpose_code || null, + selection_preset: normalized.selection.selection_preset ?? null, source: describeSourceBinding(sourceBinding), - stream_names: normalized.selection.streams.map((stream) => stream.name), + stream_names: (normalized.selection.streams ?? []).map((stream) => stream.name), user_code: userCode, }; @@ -4141,7 +5648,7 @@ export async function initiateGrant( }, purpose_code: normalized.selection.purpose_code || null, source: describeSourceBinding(sourceBinding), - stream_names: normalized.selection.streams.map((stream) => stream.name), + stream_names: (normalized.selection.streams ?? []).map((stream) => stream.name), }, event_type: "request.rejected", object_id: traceContext.request_id, @@ -4161,6 +5668,7 @@ function asSingleEntryRequestSlice(batchRequest: StagedBatchRequest, entry: Batc request_kind: "pdpp_selection_request", request_version: batchRequest.request_version, selection: entry.selection, + ...(entry.source_declaration_snapshot ? { source_declaration_snapshot: entry.source_declaration_snapshot } : {}), source_binding: entry.source_binding, storage_binding: entry.storage_binding, ...(entry.manifest_version ? { manifest_version: entry.manifest_version } : {}), @@ -4172,7 +5680,7 @@ async function initiateStagedGrantBatch( input: Record<string, unknown>, opts: InitiateGrantOptions = {} ): Promise<Record<string, unknown>> { - const batch = normalizeStagedGrantRequestBatch(input, opts); + const batch = await normalizeStagedGrantRequestBatch(input, opts); const traceContext = getRequestTraceContext( batch, opts.scenarioId || (isNonEmptyString(input.scenario_id) ? input.scenario_id : null) @@ -4208,8 +5716,10 @@ async function initiateStagedGrantBatch( entry.source_binding = describeSourceBinding(sourceBinding); entry.storage_binding = normalizeStorageBinding(storageBinding); const manifest = await requireGrantManifestForBindings(sourceBinding, storageBinding, opts); - resolveGrantSelection(entry.selection, manifest); - entry.manifest_version = requireManifestVersion(manifest); + await retainSourceDeclarationSnapshot(slice, sourceBinding, storageBinding, manifest, opts); + resolvePendingRequestAgainstSnapshot(slice); + entry.source_declaration_snapshot = slice.source_declaration_snapshot; + entry.manifest_version = slice.manifest_version; }); const deviceCode = generateId("dc"); @@ -4284,34 +5794,51 @@ async function initiateStagedGrantBatch( } } -async function buildBatchConsentCards( - request: StagedBatchRequest, - opts: { nativeManifest?: DbRow | null } = {} -): Promise<Record<string, unknown>[]> { - const cards: Record<string, unknown>[] = []; - await forEachSequential(request.entries, async (entry, index) => { +function buildBatchConsentCards(request: StagedBatchRequest): Record<string, unknown>[] { + return request.entries.map((entry, index) => { const slice = asSingleEntryRequestSlice(request, entry); requireStructuredPendingRequestShape(slice); const { sourceBinding, storageBinding } = requireStructuredPendingRequestBindings(slice); entry.source_binding = describeSourceBinding(sourceBinding); entry.storage_binding = normalizeStorageBinding(storageBinding); - const manifest = await requireGrantManifestForBindings(sourceBinding, storageBinding, opts); - slice.manifest_version = entry.manifest_version; - const resolvedStreams = requirePendingRequestContractAgainstManifest(slice, manifest); - cards.push({ + const snapshot = readRetainedSourceDeclarationSnapshot(slice); + const resolvedStreams = resolvePendingRequestAgainstSnapshot(slice); + return { access_mode: entry.selection?.access_mode || null, + client_claims: entry.selection?.client_claims ?? null, index, - manifestStreamNames: Array.isArray(manifest.streams) - ? manifest.streams.map((stream) => stream.name).filter((name) => typeof name === "string") + manifestStreamNames: Array.isArray(snapshot.declaration.streams) + ? snapshot.declaration.streams.map((stream) => stream.name).filter((name) => typeof name === "string") : null, purpose_code: entry.selection?.purpose_code || null, resolvedStreams, retention: entry.selection?.retention ?? null, - sensitivity: resolveManifestSensitivity(manifest), + sensitivity: snapshot.source_sensitivity, source: describeSourceBinding(sourceBinding), - }); + }; + }); +} + +function buildBatchConsentCardsFromReviewArtifact( + artifact: Record<string, unknown> & { subject?: { id?: unknown } } +): Record<string, unknown>[] { + requireBatchApprovalReviewArtifact(artifact); + return artifact.sources.map((source) => { + if (!isRecord(source)) { + throw bindingError("invalid_request", "Pending batch review has invalid source facts; review the request again"); + } + return { + access_mode: source.access_mode || null, + client_claims: normalizeApprovalReviewClientClaims(source.client_claims), + index: source.index, + manifestStreamNames: null, + purpose_code: source.purpose_code || null, + resolvedStreams: source.resolved_streams, + retention: source.retention ?? null, + sensitivity: null, + source: source.source, + }; }); - return cards; } function summarizeBatchCumulativeRisk(cards: Record<string, unknown>[] = []): Record<string, unknown> { @@ -4348,7 +5875,7 @@ function cardHasNoTimeBound(card: Record<string, unknown>): boolean { if (resolved.length === 0) { return true; } - return resolved.some((stream) => !(isRecord(stream) && stream.time_range)); + return resolved.some((stream) => !(isRecord(stream) && stream.time_constraint)); } function evaluateBatchApproveAllGate(cards: Record<string, unknown>[] = []): { @@ -4374,10 +5901,10 @@ function evaluateBatchApproveAllGate(cards: Record<string, unknown>[] = []): { // Owner-driven per-source narrowing applied at approval time. The owner may // reduce a staged entry's streams, reduce a stream's fields, and tighten a -// `time_range.since` bound, but MUST NOT widen beyond what the client +// `time_constraint.since` bound, but MUST NOT widen beyond what the client // staged and the owner reviewed. Narrowing is validated against the staged -// resolved baseline (`resolveGrantSelection(entry.selection, manifest)`), which -// is the authoritative ceiling of what the client asked for. Anything not a +// resolved baseline retained in the declaration snapshot, which is the +// authoritative ceiling of what the client asked for. Anything not a // subset/tightening of that baseline is rejected before any grant is issued. // // Shape (per staged source index): @@ -4502,13 +6029,14 @@ function applyFieldNarrowing(narrowed: StreamSelection, requestedFields: unknown function applySinceNarrowing(narrowed: StreamSelection, requestedSince: unknown, sourceLabel: string): void { const { name } = narrowed; - const baselineSince = narrowed.time_range?.since; - if (!isNonEmptyString(baselineSince)) { + const baselineConstraint = narrowed.time_constraint; + if (!(baselineConstraint && isNonEmptyString(baselineConstraint.since))) { throw bindingError( "invalid_request", `Cannot set a time bound on '${sourceLabel}' stream '${name}': the staged request placed no time bound on it, so a tighter bound cannot be proven against it` ); } + const baselineSince = baselineConstraint.since; const requestedMs = parseIsoInstant(requestedSince, { sourceLabel, streamName: name }); if (!isNonEmptyString(requestedSince)) { throw bindingError( @@ -4523,18 +6051,20 @@ function applySinceNarrowing(narrowed: StreamSelection, requestedSince: unknown, `Cannot narrow '${sourceLabel}' stream '${name}' to start at '${requestedSince}': that is earlier than the staged bound '${baselineSince}' (widening is forbidden)` ); } - narrowed.time_range = { ...narrowed.time_range, since: requestedSince }; + narrowed.time_constraint = { ...baselineConstraint, since: requestedSince }; } function narrowResolvedStream( baseStream: StreamSelection, fieldsNarrowing: Record<string, unknown>, sinceNarrowing: Record<string, unknown>, - sourceLabel: string + sourceLabel: string, + requiredFields: string[] ): StreamSelection { const narrowed = { ...baseStream }; if (Object.hasOwn(fieldsNarrowing, narrowed.name)) { applyFieldNarrowing(narrowed, fieldsNarrowing[narrowed.name], sourceLabel); + narrowed.fields = [...new Set([...(narrowed.fields ?? []), ...requiredFields])]; } if (Object.hasOwn(sinceNarrowing, narrowed.name)) { applySinceNarrowing(narrowed, sinceNarrowing[narrowed.name], sourceLabel); @@ -4545,7 +6075,9 @@ function narrowResolvedStream( function narrowResolvedSelectionForSource( baselineResolved: StreamSelection[], narrowing: Record<string, unknown> | null | undefined, - sourceLabel: string + sourceLabel: string, + declaration: DbRow, + requiredStreamNames: readonly string[] = [] ): StreamSelection[] { const baseline = Array.isArray(baselineResolved) ? baselineResolved : []; if (!narrowingHasAnyDirective(narrowing)) { @@ -4553,6 +6085,13 @@ function narrowResolvedSelectionForSource( } const baselineByName = new Map(baseline.map((stream) => [stream.name, stream])); const keptNames = resolveKeptStreamNames(baseline, narrowing.streams, baselineByName, sourceLabel); + const droppedRequired = requiredStreamNames.filter((name) => !keptNames.includes(name)); + if (droppedRequired.length > 0) { + throw bindingError( + "invalid_request", + `Cannot drop required streams for '${sourceLabel}': ${droppedRequired.join(", ")}` + ); + } const fieldsNarrowing = isRecord(narrowing.fields) ? narrowing.fields : {}; const sinceNarrowing = isRecord(narrowing.since) ? narrowing.since : {}; requireNarrowingTargetsKept(keptNames, [fieldsNarrowing, sinceNarrowing], sourceLabel); @@ -4561,18 +6100,72 @@ function narrowResolvedSelectionForSource( if (!baseStream) { throw bindingError("invalid_request", `Unknown staged stream '${name}' for '${sourceLabel}'`); } - return narrowResolvedStream(baseStream, fieldsNarrowing, sinceNarrowing, sourceLabel); + const declarationStream = getManifestStreams(declaration).find((stream) => stream.name === name); + if (!declarationStream) { + throw bindingError("invalid_request", `Retained declaration has no stream '${name}' for '${sourceLabel}'`); + } + return narrowResolvedStream( + baseStream, + fieldsNarrowing, + sinceNarrowing, + sourceLabel, + coreSchemaRequiredFields( + declarationStream as unknown as import("@pdpp/reference-contract/public/source").SourceDeclarationStream + ) + ); }); } +interface PendingConsentDisplayOptions extends Record<string, unknown> { + ai_training_consented?: unknown; + approvedSourceIndexes?: number[] | null; + confirmedApproveAll?: boolean; + finalizeReview?: boolean; + nativeManifest?: DbRow | null; + sourceNarrowing?: Record<string, unknown>; + subjectId?: string | null; +} + async function getPendingConsentBatch( request: StagedBatchRequest, - row: DbRow, - opts: { nativeManifest?: DbRow | null } = {} + row: PendingConsentRow, + opts: PendingConsentDisplayOptions = {} ): Promise<Record<string, unknown>> { try { - await requirePendingRequestClientRegistration(request, opts); - const cards = await buildBatchConsentCards(request); + let cards: Record<string, unknown>[]; + let review: (Record<string, unknown> & { subject?: { id?: unknown } }) | null = null; + let reviewArtifact: string | null = null; + let reviewDigest: string | null = null; + let reviewRevision: string | null = null; + const hasFinalChoice = + opts.approvedSourceIndexes !== undefined || + opts.sourceNarrowing !== undefined || + opts.confirmedApproveAll === true; + if (opts.ai_training_consented !== undefined && hasFinalChoice) { + throw bindingError("invalid_request", "Batch approval review does not accept ai_training_consented"); + } + if (opts.finalizeReview && opts.subjectId && hasFinalChoice) { + const batchState = await buildReviewedBatchApprovalState(request, row, opts.subjectId, { + ...opts, + }); + await persistApprovalReviewArtifact({ deviceCode: row.device_code, ...batchState.review }); + const persisted = await readValidatedPersistedApprovalReview(row.device_code); + review = persisted.artifact; + reviewArtifact = persisted.artifactJson; + reviewDigest = persisted.digest; + reviewRevision = persisted.revision; + cards = buildBatchConsentCardsFromReviewArtifact(review); + } else if (hasPersistedApprovalReview(row)) { + const persisted = await readValidatedPersistedApprovalReview(row.device_code); + review = persisted.artifact; + reviewArtifact = persisted.artifactJson; + reviewDigest = persisted.digest; + reviewRevision = persisted.revision; + cards = buildBatchConsentCardsFromReviewArtifact(review); + } else { + await requirePendingRequestClientRegistration(request, opts); + cards = await buildBatchConsentCards(request); + } return { approveAllGate: evaluateBatchApproveAllGate(cards), batch: true, @@ -4583,6 +6176,10 @@ async function getPendingConsentBatch( overCapSources: Array.isArray(request.over_cap_sources) ? request.over_cap_sources : [], overSoftCap: Boolean(request.over_soft_cap), request, + review, + reviewArtifact, + reviewDigest, + reviewRevision, softCap: request.soft_cap, softCapWarning: Boolean(request.soft_cap_warning), userCode: row.user_code, @@ -4595,7 +6192,13 @@ async function getPendingConsentBatch( await emitPendingConsentRejected( { client: request.client, - ...(firstEntry ? { selection: firstEntry.selection, source_binding: firstEntry.source_binding } : {}), + ...(firstEntry + ? { + selection: firstEntry.selection, + source_binding: firstEntry.source_binding, + source_declaration_snapshot: firstEntry.source_declaration_snapshot, + } + : {}), }, row, err @@ -4630,14 +6233,30 @@ function resolveApprovedEntryIndexes( } interface ApproveStagedGrantBatchOptions { - ai_training_consented?: boolean | null; + approval_review_revision?: unknown; approvedSourceIndexes?: number[] | null; confirmedApproveAll?: boolean; narrowings?: Record<string, unknown>[] | null; nativeManifest?: DbRow | null; + reviewExpiresAt?: string | null; sourceNarrowing?: Record<string, unknown>; } +interface ReviewedBatchApprovalState { + approvedIndexes: number[]; + parentPackage: GrantPackageNormalized | null; + registeredClient: RegisteredClient; + resolvedEntries: { + entry: BatchEntry; + index: number; + resolvedStreams: ResolvedGrantStream[]; + slice: PendingRequest; + sourceBinding: SourceBinding; + storageBinding: StorageBinding; + }[]; + review: { artifactJson: string; digest: string; revision: string }; +} + async function rejectStagedBatchApproval( request: StagedBatchRequest, pending: DbRow, @@ -4648,7 +6267,13 @@ async function rejectStagedBatchApproval( await emitPendingConsentRejected( { client: request.client, - ...(firstEntry ? { selection: firstEntry.selection, source_binding: firstEntry.source_binding } : {}), + ...(firstEntry + ? { + selection: firstEntry.selection, + source_binding: firstEntry.source_binding, + source_declaration_snapshot: firstEntry.source_declaration_snapshot, + } + : {}), }, pending, err, @@ -4694,7 +6319,7 @@ async function resolveApprovedBatchEntries( const approvedIndexes = resolveApprovedEntryIndexes(request, opts); const isApproveAll = opts.approvedSourceIndexes === undefined || opts.approvedSourceIndexes === null; if (isApproveAll) { - const gate = evaluateBatchApproveAllGate(await buildBatchConsentCards(request, opts)); + const gate = evaluateBatchApproveAllGate(await buildBatchConsentCards(request)); if (gate.approve_all_suppressed) { const err: AuthError = new Error( `Approve-all is not available for this request (${gate.suppression_reasons.join(", ")}); confirm each source individually` @@ -4745,21 +6370,46 @@ async function approveStagedGrantBatch( deviceCode: string, pending: DbRow, request: StagedBatchRequest, - subjectId: string, opts: ApproveStagedGrantBatchOptions = {} ): Promise<{ grant: DbRow; package: boolean; package_id: string; token: string }> { const traceContext = requirePersistedPendingTraceContext(pending); request.trace_context = traceContext; + const reviewed = requireMatchingApprovalReview(pending as PendingConsentRow, opts.approval_review_revision); + const { subjectId } = reviewed; + const persistedOptions = persistedBatchReviewOptions(pending as PendingConsentRow); + const batchState = await buildReviewedBatchApprovalState(request, pending, subjectId, persistedOptions); + if ( + batchState.review.revision !== pending.approval_review_revision || + batchState.review.digest !== pending.approval_review_digest + ) { + throw bindingError("invalid_request", "Pending consent review is stale"); + } + + return await persistApprovedBatchGrantAtomically({ + deviceCode, + pending, + subjectId, + traceContext, + ...batchState, + }); +} + +async function buildReviewedBatchApprovalState( + request: StagedBatchRequest, + pending: DbRow, + subjectId: string, + opts: ApproveStagedGrantBatchOptions = {} +): Promise<ReviewedBatchApprovalState> { const { approvedEntries, approvedIndexes } = await resolveApprovedBatchEntries(request, pending, subjectId, opts); const sourceNarrowing = opts.sourceNarrowing && typeof opts.sourceNarrowing === "object" ? opts.sourceNarrowing : {}; + requireNumericObjectKeys(sourceNarrowing, "source_narrowing"); requireApprovedSourceNarrowings(sourceNarrowing, approvedIndexes); - let registeredClient: RegisteredClient; let parentPackage: GrantPackageNormalized | null = null; const resolvedEntries: { entry: BatchEntry; - manifest: DbRow; - resolvedStreams: StreamSelection[]; + index: number; + resolvedStreams: ResolvedGrantStream[]; slice: PendingRequest; sourceBinding: SourceBinding; storageBinding: StorageBinding; @@ -4775,7 +6425,7 @@ async function approveStagedGrantBatch( clientId: registeredClient.client_id, subjectId, }); - await forEachSequential(approvedEntries, async (entry, position) => { + for (const [position, entry] of approvedEntries.entries()) { const stagedIndex = approvedIndexes[position]; if (stagedIndex === undefined) { throw bindingError("invalid_request", `Approved source position ${position} is unavailable`); @@ -4785,20 +6435,27 @@ async function approveStagedGrantBatch( const { sourceBinding, storageBinding } = requireStructuredPendingRequestBindings(slice); entry.source_binding = describeSourceBinding(sourceBinding); entry.storage_binding = normalizeStorageBinding(storageBinding); - const manifest = await requireGrantManifestForBindings(sourceBinding, storageBinding, opts); - slice.manifest_version = entry.manifest_version; - const baselineStreams = requirePendingRequestContractAgainstManifest(slice, manifest); + const baselineStreams = resolvePendingRequestAgainstSnapshot(slice); + const retainedDeclaration = readRetainedSourceDeclarationSnapshot(slice).declaration; // Apply owner per-source narrowing against the staged resolved baseline. // narrowResolvedSelectionForSource proves the result is a subset/tightening // of what the client staged; widening throws invalid_request here, before // any package row or child grant is written. - const resolvedStreams = narrowResolvedSelectionForSource( + const narrowedStreams = narrowResolvedSelectionForSource( baselineStreams, isRecord(sourceNarrowing[stagedIndex]) ? sourceNarrowing[stagedIndex] : null, - sourceBinding.id || `source ${stagedIndex + 1}` + sourceBinding.id || `source ${stagedIndex + 1}`, + retainedDeclaration ); - resolvedEntries.push({ entry, manifest, resolvedStreams, slice, sourceBinding, storageBinding }); - }); + // biome-ignore lint/performance/noAwaitInLoops: Per-source eligibility is intentionally resolved in stable approval order before any package write. + const resolvedStreams = await resolveSnapshotStreamsForApproval( + narrowedStreams, + sourceBinding, + storageBinding, + subjectId + ); + resolvedEntries.push({ entry, index: stagedIndex, resolvedStreams, slice, sourceBinding, storageBinding }); + } } catch (err: unknown) { if (!isAuthError(err)) { throw err; @@ -4807,7 +6464,13 @@ async function approveStagedGrantBatch( await emitPendingConsentRejected( { client: request.client, - ...(firstEntry ? { selection: firstEntry.selection, source_binding: firstEntry.source_binding } : {}), + ...(firstEntry + ? { + selection: firstEntry.selection, + source_binding: firstEntry.source_binding, + source_declaration_snapshot: firstEntry.source_declaration_snapshot, + } + : {}), }, pending, err, @@ -4815,71 +6478,107 @@ async function approveStagedGrantBatch( ); throw err; } + const [firstResolvedEntry] = resolvedEntries; + if (!firstResolvedEntry) { + throw bindingError("invalid_request", "No approved batch entries are available"); + } + const expiresAt = + firstResolvedEntry.entry.selection.access_mode === "single_use" + ? (opts.reviewExpiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()) + : null; + const review = buildBatchApprovalReviewArtifact({ + approvedIndexes, + client: request.client, + entries: resolvedEntries.map((entry) => ({ + index: entry.index, + request: entry.slice, + resolvedStreams: entry.resolvedStreams, + })), + expiresAt, + parentPackageId: parentPackage ? parentPackage.package_id : null, + sourceNarrowing, + subjectId, + }); + return { + approvedIndexes, + parentPackage, + registeredClient, + resolvedEntries, + review, + }; +} +async function persistApprovedBatchGrantAtomically({ + approvedIndexes, + deviceCode, + parentPackage, + pending, + registeredClient, + resolvedEntries, + review, + subjectId, + traceContext, +}: ReviewedBatchApprovalState & { + deviceCode: string; + pending: DbRow; + subjectId: string; + traceContext: TraceContext; +}): Promise<{ grant: DbRow; package: boolean; package_id: string; token: string }> { const packageId = generateId("gpkg"); const createdAt = nowIso(); const packageEnvelope = { approved_source_count: resolvedEntries.length, - approved_source_indexes: approvedIndexes, client: { + client_display: buildClientDisplayFromRegistration(registeredClient.metadata), client_id: registeredClient.client_id, - ...(request.client.client_display ? { client_display: request.client.client_display } : {}), + registration_mode: registeredClient.registration_mode || "pre_registered_public", }, package_id: packageId, source_bounded_child_grants: true, - staged_source_count: request.entries.length, subject: { id: subjectId }, - version: "reference.batch_consent.v1", - ...(parentPackage ? { parent_package_id: parentPackage.package_id } : {}), + version: CURRENT_GRANT_PACKAGE_VERSION, }; const parentPackageId = parentPackage ? parentPackage.package_id : null; - await getGrantPackageStore().insertPackage({ - approvedAt: createdAt, - clientId: registeredClient.client_id, - createdAt, - packageId, - packageJson: JSON.stringify(packageEnvelope), - parentPackageId, - scenarioId: traceContext.scenario_id ?? null, - subjectId, - traceId: traceContext.trace_id, - }); - + const reviewPayload = JSON.parse(review.artifactJson) as { expires_at?: string | null }; const childGrants: { grant: GrantEnvelope; source: Record<string, unknown> | null; token: string }[] = []; - await forEachSequential(resolvedEntries, async (resolved) => { - const { grant, token } = await persistChildGrantForPackage({ - manifest: resolved.manifest, - registeredClient, - request: resolved.slice, + for (const resolved of resolvedEntries) { + const grantId = generateId("grt"); + const issuedAt = createdAt; + const expiresAt = resolved.entry.selection.access_mode === "single_use" ? (reviewPayload.expires_at ?? null) : null; + const grant = materializeCoreResolvedGrant({ + accessMode: resolved.entry.selection.access_mode, + clientId: registeredClient.client_id, + expiresAt, + grantId, + issuedAt, + purposeCode: resolved.entry.selection.purpose_code, + purposeDescription: resolved.entry.selection.purpose_description, resolvedStreams: resolved.resolvedStreams, - sourceBinding: resolved.sourceBinding, - storageBinding: resolved.storageBinding, + retention: resolved.entry.selection.retention, + selectionPreset: resolved.entry.selection.selection_preset, + snapshot: readRetainedSourceDeclarationSnapshot( + resolved.slice + ) as unknown as import("./core-source-authorization.ts").RetainedCoreConsentSnapshot, subjectId, - traceContext, - }); + }) as unknown as GrantEnvelope; const source = describePackageMemberSource(grant); if (!isNonEmptyString(grant.grant_id)) { throw bindingError("grant_invalid", "Issued child grant is missing grant_id"); } - const addedAt = nowIso(); - await getGrantPackageStore().insertPackageMember({ - addedAt, - grantId: grant.grant_id, - packageId, - sourceJson: JSON.stringify(source), - tokenId: token, - }); - childGrants.push({ grant, source, token }); - }); + childGrants.push({ grant, source, token: "" }); + } - await emitSpineEvent({ + const consentApprovedEvent: AuthSpineEventInput = { actor_id: subjectId, actor_type: "subject", client_id: registeredClient.client_id, data: { approved_source_indexes: approvedIndexes, package_id: packageId, + sources: resolvedEntries.map((resolved) => + buildResolvedSnapshotEvidence(resolved.slice, resolved.resolvedStreams) + ), user_code: pending.user_code, }, event_type: "consent.approved", @@ -4891,14 +6590,8 @@ async function approveStagedGrantBatch( subject_id: subjectId, subject_type: "subject", trace_id: traceContext.trace_id, - }); - - const packageToken = await issuePackageToken(packageId, subjectId, registeredClient.client_id, null, { - source: "batch_consent_package", - traceContext, - }); - - await emitSpineEvent({ + }; + const grantPackageIssuedEvent = (issuedPackageToken: string): AuthSpineEventInput => ({ actor_id: "pdpp_as", actor_type: "authorization_server", client_id: registeredClient.client_id, @@ -4914,15 +6607,25 @@ async function approveStagedGrantBatch( status: "succeeded", subject_id: subjectId, subject_type: "subject", - token_id: packageToken, + token_id: issuedPackageToken, trace_id: traceContext.trace_id, }); - await markPendingConsentApproved(deviceCode, { - aiTrainingConsented: false, - grantId: packageId, + const packageToken = await persistApprovedBatchRowsAtomically({ + childGrants, + clientId: registeredClient.client_id, + consentApprovedEvent, + createdAt, + deviceCode, + grantPackageIssuedEvent, + packageEnvelope, + packageId, + parentPackageId, + pending, + resolvedEntries, + reviewRevision: review.revision, subjectId, - tokenId: packageToken, + traceContext, }); return { @@ -4941,18 +6644,384 @@ async function approveStagedGrantBatch( }; } +async function persistApprovedBatchRowsAtomically(input: { + childGrants: { grant: GrantEnvelope; source: Record<string, unknown> | null; token: string }[]; + clientId: string; + consentApprovedEvent: AuthSpineEventInput; + createdAt: string; + deviceCode: string; + grantPackageIssuedEvent: (packageToken: string) => AuthSpineEventInput; + packageEnvelope: Record<string, unknown>; + packageId: string; + parentPackageId: string | null; + pending: DbRow; + resolvedEntries: { + entry: BatchEntry; + resolvedStreams: ResolvedGrantStream[]; + slice: PendingRequest; + storageBinding: StorageBinding; + }[]; + reviewRevision: string; + subjectId: string; + traceContext: TraceContext; +}): Promise<string> { + const packageJson = JSON.stringify(input.packageEnvelope); + if (isPostgresStorageBackend()) { + return await withPostgresTransaction(async (client) => { + const claim = await client.query( + `UPDATE pending_consents + SET status = 'approving', subject_id = $2 + WHERE device_code = $1 + AND status = 'pending' + AND approval_review_revision = $3 + RETURNING device_code`, + [input.deviceCode, input.subjectId, input.reviewRevision] + ); + if (claim.rowCount !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + await requirePostgresParentPackageStillEligible(client, { + clientId: input.clientId, + parentPackageId: input.parentPackageId, + subjectId: input.subjectId, + }); + await requirePostgresReviewedInstancesActive( + client, + input.resolvedEntries.flatMap((entry) => + reviewedInstanceChecksForGrant({ + acceptedRevisionFulfillment: Boolean( + readRetainedSourceDeclarationSnapshot(entry.slice).accepted_revision_reference + ), + resolvedStreams: entry.resolvedStreams, + sourceBinding: entry.slice.source_binding as SourceBinding, + storageBinding: entry.storageBinding, + subjectId: input.subjectId, + }) + ) + ); + await client.query( + `INSERT INTO grant_packages( + package_id, subject_id, client_id, status, package_json, + parent_package_id, trace_id, scenario_id, created_at, approved_at, revoked_at + ) VALUES($1, $2, $3, 'active', $4::jsonb, $5, $6, $7, $8, $9, NULL)`, + [ + input.packageId, + input.subjectId, + input.clientId, + packageJson, + input.parentPackageId, + input.traceContext.trace_id, + input.traceContext.scenario_id ?? null, + input.createdAt, + input.createdAt, + ] + ); + for (const [index, child] of input.childGrants.entries()) { + const resolved = input.resolvedEntries[index]; + if (!resolved) { + throw bindingError("grant_invalid", `Missing resolved batch entry ${index}`); + } + // biome-ignore lint/performance/noAwaitInLoops: Child grant/package rows must be written in package member order inside one transaction. + await client.query( + `INSERT INTO grants( + grant_id, subject_id, client_id, storage_binding_json, grant_json, + access_mode, issued_at, expires_at, trace_id, scenario_id + ) VALUES($1, $2, $3, $4::jsonb, $5::jsonb, $6, $7, $8, $9, $10)`, + [ + child.grant.grant_id, + input.subjectId, + input.clientId, + serializeStorageBinding(normalizeStorageBinding(resolved.storageBinding)), + JSON.stringify(child.grant), + resolved.entry.selection.access_mode, + child.grant.issued_at, + child.grant.expires_at ?? null, + input.traceContext.trace_id, + input.traceContext.scenario_id ?? null, + ] + ); + const { tokenId } = await insertPostgresGrantToken(client, { + clientId: input.clientId, + expiresAt: child.grant.expires_at ?? null, + grantId: child.grant.grant_id as string, + subjectId: input.subjectId, + }); + child.token = tokenId; + await client.query( + `INSERT INTO grant_package_members( + package_id, grant_id, token_id, source_json, status, added_at, revoked_at + ) VALUES($1, $2, $3, $4::jsonb, 'active', $5, NULL)`, + [input.packageId, child.grant.grant_id, tokenId, JSON.stringify(child.source), input.createdAt] + ); + await postgresEmitSpineEventInTransaction( + client, + buildGrantIssuedEventInput({ + grant: child.grant, + grantId: child.grant.grant_id as string, + registeredClient: { + client_id: input.clientId, + client_secret: null, + created_at: null, + metadata: { token_endpoint_auth_method: "none" }, + registration_mode: "pre_registered_public", + token_endpoint_auth_method: "none", + updated_at: null, + }, + request: resolved.slice, + resolvedStreams: resolved.resolvedStreams, + selection: resolved.entry.selection, + subjectId: input.subjectId, + traceContext: input.traceContext, + }) as SpineEventInput + ); + await postgresEmitSpineEventInTransaction( + client, + buildTokenIssuedEventInput({ + clientId: input.clientId, + grant: child.grant, + grantId: child.grant.grant_id as string, + subjectId: input.subjectId, + tokenId, + traceContext: input.traceContext, + }) as SpineEventInput + ); + } + await postgresEmitSpineEventInTransaction(client, input.consentApprovedEvent as SpineEventInput); + const packageToken = generateToken(); + await client.query( + `INSERT INTO tokens(token_id, grant_id, package_id, subject_id, client_id, token_kind, expires_at) + VALUES($1, NULL, $2, $3, $4, 'mcp_package', NULL)`, + [packageToken, input.packageId, input.subjectId, input.clientId] + ); + await postgresEmitSpineEventInTransaction(client, input.grantPackageIssuedEvent(packageToken) as SpineEventInput); + const finalApproval = await client.query( + `UPDATE pending_consents + SET status = 'approved', + subject_id = $2, + grant_id = $3, + token_id = $4, + ai_training_consented = FALSE, + approved_at = $5 + WHERE device_code = $1 + AND status = 'approving'`, + [input.deviceCode, input.subjectId, input.packageId, packageToken, input.createdAt] + ); + if (finalApproval.rowCount !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + return packageToken; + }); + } + + return transaction(() => { + const db = getDb(); + const claim = db + .prepare( + `UPDATE pending_consents + SET status = 'approving', subject_id = ? + WHERE device_code = ? + AND status = 'pending' + AND approval_review_revision = ?` + ) + .run(input.subjectId, input.deviceCode, input.reviewRevision); + if (claim.changes !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + requireSqliteParentPackageStillEligible({ + clientId: input.clientId, + parentPackageId: input.parentPackageId, + subjectId: input.subjectId, + }); + requireSqliteReviewedInstancesActive( + input.resolvedEntries.flatMap((entry) => + reviewedInstanceChecksForGrant({ + acceptedRevisionFulfillment: Boolean( + readRetainedSourceDeclarationSnapshot(entry.slice).accepted_revision_reference + ), + resolvedStreams: entry.resolvedStreams, + sourceBinding: entry.slice.source_binding as SourceBinding, + storageBinding: entry.storageBinding, + subjectId: input.subjectId, + }) + ) + ); + exec(referenceQueries.authGrantPackagesInsert, [ + input.packageId, + input.subjectId, + input.clientId, + packageJson, + input.parentPackageId, + input.traceContext.trace_id, + input.traceContext.scenario_id ?? null, + input.createdAt, + input.createdAt, + ]); + for (const [index, child] of input.childGrants.entries()) { + const resolved = input.resolvedEntries[index]; + if (!resolved) { + throw bindingError("grant_invalid", `Missing resolved batch entry ${index}`); + } + exec(referenceQueries.authGrantsInsert, [ + child.grant.grant_id, + input.subjectId, + input.clientId, + serializeStorageBinding(normalizeStorageBinding(resolved.storageBinding)), + JSON.stringify(child.grant), + resolved.entry.selection.access_mode, + child.grant.issued_at, + child.grant.expires_at ?? null, + input.traceContext.trace_id, + input.traceContext.scenario_id ?? null, + ]); + const { tokenId } = insertSqliteGrantTokenInCurrentTransaction({ + clientId: input.clientId, + expiresAt: child.grant.expires_at ?? null, + grantId: child.grant.grant_id as string, + subjectId: input.subjectId, + }); + child.token = tokenId; + exec(referenceQueries.authGrantPackageMembersInsert, [ + input.packageId, + child.grant.grant_id, + tokenId, + JSON.stringify(child.source), + input.createdAt, + ]); + emitRawSpineEvent( + buildGrantIssuedEventInput({ + grant: child.grant, + grantId: child.grant.grant_id as string, + registeredClient: { + client_id: input.clientId, + client_secret: null, + created_at: null, + metadata: { token_endpoint_auth_method: "none" }, + registration_mode: "pre_registered_public", + token_endpoint_auth_method: "none", + updated_at: null, + }, + request: resolved.slice, + resolvedStreams: resolved.resolvedStreams, + selection: resolved.entry.selection, + subjectId: input.subjectId, + traceContext: input.traceContext, + }) as SpineEventInput, + db + ); + emitRawSpineEvent( + buildTokenIssuedEventInput({ + clientId: input.clientId, + grant: child.grant, + grantId: child.grant.grant_id as string, + subjectId: input.subjectId, + tokenId, + traceContext: input.traceContext, + }) as SpineEventInput, + db + ); + } + emitRawSpineEvent(input.consentApprovedEvent as SpineEventInput, db); + const packageToken = generateToken(); + exec(referenceQueries.authTokensInsertMcpPackage, [ + packageToken, + input.packageId, + input.subjectId, + input.clientId, + null, + ]); + emitRawSpineEvent(input.grantPackageIssuedEvent(packageToken) as SpineEventInput, db); + const finalApproval = db + .prepare( + `UPDATE pending_consents + SET status = 'approved', + subject_id = ?, + grant_id = ?, + token_id = ?, + ai_training_consented = 0, + approved_at = ? + WHERE device_code = ? + AND status = 'approving'` + ) + .run(input.subjectId, input.packageId, packageToken, input.createdAt, input.deviceCode); + if (finalApproval.changes !== 1) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } + return packageToken; + }); +} + +async function getReviewedPendingConsentProjection( + deviceCode: string, + row: PendingConsentRow, + request: Record<string, unknown>, + opts: PendingConsentDisplayOptions +): Promise<Record<string, unknown>> { + const persisted = await readValidatedPersistedApprovalReview(deviceCode); + if (persisted.artifact.version === "reference.batch-approval-review.v1") { + if (!isStagedBatchRequest(request)) { + throw bindingError("invalid_request", "Pending consent review does not match the staged request"); + } + return getPendingConsentBatch(request, row, opts); + } + const artifactStreams = Array.isArray(persisted.artifact.resolved_streams) + ? (persisted.artifact.resolved_streams as StreamSelection[]) + : []; + return { + createdAt: row.created_at, + expiresAt: row.expires_at, + manifestStreamNames: null, + request, + resolvedStreams: artifactStreams, + review: persisted.artifact, + reviewArtifact: persisted.artifactJson, + reviewDigest: persisted.digest, + reviewRevision: persisted.revision, + userCode: row.user_code, + }; +} + +async function resolveSingleConsentReviewStreams( + request: PendingRequest, + sourceBinding: SourceBinding, + storageBinding: StorageBinding, + opts: PendingConsentDisplayOptions +): Promise<ResolvedGrantStream[] | StreamSelection[]> { + const baselineStreams = resolvePendingRequestAgainstSnapshot(request); + const sourceNarrowing = opts.sourceNarrowing && typeof opts.sourceNarrowing === "object" ? opts.sourceNarrowing : {}; + requireNumericObjectKeys(sourceNarrowing, "source_narrowing"); + requireApprovedSourceNarrowings(sourceNarrowing, [0]); + const requiredStreamNames = (request.selection.streams ?? []) + .filter((stream) => stream.necessity !== "optional" && isNonEmptyString(stream.name) && stream.name !== "*") + .map((stream) => stream.name as string); + const narrowedStreams = narrowResolvedSelectionForSource( + baselineStreams, + isRecord(sourceNarrowing["0"]) ? sourceNarrowing["0"] : null, + sourceBinding.id, + readRetainedSourceDeclarationSnapshot(request).declaration, + requiredStreamNames + ); + return opts.subjectId && storageBinding + ? await resolveSnapshotStreamsForApproval(narrowedStreams, sourceBinding, storageBinding, opts.subjectId) + : narrowedStreams; +} + /** * Get pending consent request for display in consent UI */ export async function getPendingConsent( deviceCode: string, - opts: { nativeManifest?: DbRow | null } = {} + opts: PendingConsentDisplayOptions = {} ): Promise<Record<string, unknown> | null> { const row = await getPendingConsentRow(deviceCode); - if (!row) { - return null; - } - if (row.status !== "pending") { + if (row?.status !== "pending") { return null; } if (isExpired(row)) { @@ -4967,22 +7036,47 @@ export async function getPendingConsent( throw bindingError("invalid_request", "Pending consent request payload must be an object"); } request.trace_context = requirePersistedPendingTraceContext(row); + if (hasPersistedApprovalReview(row)) { + return getReviewedPendingConsentProjection(deviceCode, row, request, opts); + } if (isStagedBatchRequest(request)) { return getPendingConsentBatch(request, row, opts); } let resolvedStreams: StreamSelection[] | null = null; let manifestStreamNames: string[] | null = null; + let review: (Record<string, unknown> & { subject?: { id?: unknown } }) | null = null; try { requireStructuredPendingRequestShape(request); await requirePendingRequestClientRegistration(request, opts); const { sourceBinding, storageBinding } = requireStructuredPendingRequestBindings(request); request.source_binding = describeSourceBinding(sourceBinding); request.storage_binding = normalizeStorageBinding(storageBinding); - const manifest = await requireGrantManifestForBindings(sourceBinding, storageBinding); - resolvedStreams = requirePendingRequestContractAgainstManifest(request, manifest); - manifestStreamNames = Array.isArray(manifest.streams) - ? manifest.streams.map((stream) => stream.name).filter((name) => typeof name === "string") + const snapshot = readRetainedSourceDeclarationSnapshot(request); + resolvedStreams = await resolveSingleConsentReviewStreams(request, sourceBinding, storageBinding, opts); + manifestStreamNames = Array.isArray(snapshot.declaration.streams) + ? snapshot.declaration.streams.map((stream) => stream.name).filter((name) => typeof name === "string") : null; + if (opts.finalizeReview && opts.subjectId) { + const aiTrainingConsented = coerceAiTrainingConsent(opts.ai_training_consented); + requireReviewedAiTrainingConsent(request.selection, aiTrainingConsented); + const artifact = buildApprovalReviewArtifact({ + aiTrainingConsented, + client: request.client, + expiresAt: + request.selection.access_mode === "single_use" + ? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + : null, + request, + resolvedStreams: resolvedStreams ?? [], + subjectId: opts.subjectId, + }); + await persistApprovalReviewArtifact({ deviceCode, ...artifact }); + const persisted = await readValidatedPersistedApprovalReview(deviceCode); + review = persisted.artifact; + row.approval_review_digest = persisted.digest; + row.approval_review_json = persisted.artifactJson; + row.approval_review_revision = persisted.revision; + } } catch (err: unknown) { if (!isAuthError(err)) { throw err; @@ -4996,6 +7090,10 @@ export async function getPendingConsent( manifestStreamNames, request, resolvedStreams, + review, + reviewArtifact: row.approval_review_json ?? null, + reviewDigest: row.approval_review_digest ?? null, + reviewRevision: row.approval_review_revision ?? null, userCode: row.user_code, }; } @@ -5007,16 +7105,37 @@ export async function getPendingConsent( */ export async function approveGrant( deviceCode: string, - subjectId = "owner_local", - opts: { ai_training_consented?: boolean | null; nativeManifest?: DbRow | null; baseUrl?: string } = {} -): Promise<{ grant: DbRow; token: string }> { + _legacySubjectId = "owner_local", + opts: { + ai_training_consented?: unknown; + approval_review_revision?: unknown; + approvedSourceIndexes?: number[] | null; + nativeManifest?: DbRow | null; + baseUrl?: string; + confirmedApproveAll?: boolean; + sourceNarrowing?: Record<string, unknown>; + } = {} +): Promise<{ grant: DbRow; package?: boolean; package_id?: string; token: string }> { + if (opts.ai_training_consented !== undefined) { + const err = bindingError("invalid_request", "ai_training_consented is only accepted during consent review"); + err.param = "ai_training_consented"; + throw err; + } const pending = await getPendingConsentRow(deviceCode); if (!pending) { const err: AuthError = new Error("Unknown device code"); err.code = "not_found"; throw err; } + if (pending.status === "approved") { + return resumeApprovedGrant(pending, _legacySubjectId); + } if (pending.status !== "pending") { + if (opts.approval_review_revision && opts.approval_review_revision === pending.approval_review_revision) { + const err: AuthError = new Error("Pending consent approval conflict"); + err.code = "approval_conflict"; + throw err; + } const err: AuthError = new Error("Pending consent request is not available"); err.code = "not_found"; throw err; @@ -5033,24 +7152,24 @@ export async function approveGrant( } const request: unknown = JSON.parse(pending.params_json); if (isStagedBatchRequest(request)) { - return approveStagedGrantBatch(deviceCode, pending, request, subjectId, opts); + return approveStagedGrantBatch(deviceCode, pending, request, opts); } requireStructuredPendingRequestShape(request); + const reviewed = requireMatchingApprovalReview(pending, opts.approval_review_revision); + const { aiTrainingConsented, subjectId } = reviewed; const traceContext = requirePersistedPendingTraceContext(pending); request.trace_context = traceContext; let registeredClient: RegisteredClient; let sourceBinding: SourceBinding; let storageBinding: StorageBinding; - let manifest: DbRow; - let resolvedStreams: StreamSelection[]; + let resolvedStreams: ResolvedGrantStream[]; try { registeredClient = await requirePendingRequestClientRegistration(request, opts); ({ sourceBinding, storageBinding } = requireStructuredPendingRequestBindings(request)); request.source_binding = describeSourceBinding(sourceBinding); request.storage_binding = normalizeStorageBinding(storageBinding); - manifest = await requireGrantManifestForBindings(sourceBinding, storageBinding, opts); - resolvedStreams = requirePendingRequestContractAgainstManifest(request, manifest); + ({ resolvedStreams } = reviewed); } catch (err: unknown) { if (!isAuthError(err)) { throw err; @@ -5059,126 +7178,161 @@ export async function approveGrant( throw err; } - const { client, selection } = request; + const { selection } = request; // The AS MUST obtain explicit affirmative consent before issuing ai_training grants. // A missing affirmation is a consent-policy rejection, not an internal failure; // surface it as a typed PDPP error envelope (status 400, code `invalid_request`) // so callers do not see it as a generic 500. - const { ai_training_consented } = opts; - if (selection.purpose_code === "https://pdpp.dev/purpose/ai_training" && !ai_training_consented) { - const err: AuthError = new Error("Explicit affirmative consent required for ai_training purpose"); - err.code = "invalid_request"; - err.param = "ai_training_consented"; - throw err; - } + requireReviewedAiTrainingConsent(selection, aiTrainingConsented); const grantId = generateId("grt"); const issuedAt = nowIso(); - const expiresAt = - selection.access_mode === "single_use" - ? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // 24h reference default - : null; + const { expiresAt } = reviewed; - const persistedSource = describeSourceBinding(sourceBinding); const persistedStorageBinding = normalizeStorageBinding(storageBinding); + const approvalArtifact = buildApprovalReviewArtifact({ + aiTrainingConsented, + client: request.client, + expiresAt, + request, + resolvedStreams, + subjectId, + }); + if ( + approvalArtifact.revision !== pending.approval_review_revision || + approvalArtifact.digest !== pending.approval_review_digest + ) { + throw bindingError("invalid_request", "Pending consent review is stale"); + } - const grant: GrantEnvelope = { - access_mode: selection.access_mode, - client: { - client_id: registeredClient.client_id, - registration_mode: registeredClient.registration_mode || "pre_registered_public", - ...(client.client_display ? { client_display: client.client_display } : {}), - }, - expires_at: expiresAt, - grant_id: grantId, - issued_at: issuedAt, - manifest_version: requireManifestVersion(manifest), - purpose_code: selection.purpose_code, - purpose_description: selection.purpose_description, + const grant = materializeCoreResolvedGrant({ + accessMode: selection.access_mode, + clientId: registeredClient.client_id, + expiresAt, + grantId, + issuedAt, + purposeCode: selection.purpose_code, + purposeDescription: selection.purpose_description, + resolvedStreams, retention: selection.retention, - source: persistedSource, - streams: resolvedStreams, - subject: { id: subjectId }, - version: "0.1.0", - }; + selectionPreset: selection.selection_preset, + snapshot: readRetainedSourceDeclarationSnapshot( + request + ) as unknown as import("./core-source-authorization.ts").RetainedCoreConsentSnapshot, + subjectId, + }) as unknown as GrantEnvelope; - // Same grants-row INSERT the grant-package child-grant flow uses; reuse the - // shared store method so the two call sites cannot drift. - await getGrantPackageStore().insertChildGrant({ + const token = await persistApprovedSingleGrantAtomically({ accessMode: selection.access_mode, + aiTrainingConsented, clientId: registeredClient.client_id, + consentApprovedEvent: buildConsentApprovedEventInput({ + deviceCode, + grantId, + pending, + registeredClient, + request, + resolvedStreams, + sourceBinding, + subjectId, + traceContext, + }), + deviceCode, expiresAt, grantId, + grantIssuedEvent: buildGrantIssuedEventInput({ + grant, + grantId, + registeredClient, + request, + resolvedStreams, + selection, + subjectId, + traceContext, + }), grantJson: JSON.stringify(grant), issuedAt, - scenarioId: traceContext.scenario_id ?? null, - storageBindingJson: serializeStorageBinding(persistedStorageBinding), + persistedStorageBinding, + reviewedInstanceChecks: reviewedInstanceChecksForGrant({ + acceptedRevisionFulfillment: Boolean(readRetainedSourceDeclarationSnapshot(request).accepted_revision_reference), + resolvedStreams, + sourceBinding, + storageBinding, + subjectId, + }), + reviewedRevision: approvalArtifact.revision, subjectId, - traceId: traceContext.trace_id, - }); - - await emitSpineEvent({ - actor_id: subjectId, - actor_type: "subject", - client_id: registeredClient.client_id, - data: { - source: describeSourceBinding(sourceBinding), - user_code: pending.user_code, - }, - event_type: "consent.approved", - grant_id: grantId, - object_id: deviceCode, - object_type: "pending_consent", - request_id: traceContext.request_id, - scenario_id: traceContext.scenario_id, - status: "succeeded", - subject_id: subjectId, - subject_type: "subject", - trace_id: traceContext.trace_id, - }); - - const grantIssuedEventData = { - access_mode: selection.access_mode, - purpose_code: selection.purpose_code, - retention: selection.retention ?? null, - source: describeGrantSource(grant), - stream_names: resolvedStreams.map((stream) => stream.name), - }; - - await emitSpineEvent({ - actor_id: "pdpp_as", - actor_type: "authorization_server", - client_id: registeredClient.client_id, - data: grantIssuedEventData, - event_type: "grant.issued", - grant_id: grantId, - object_id: grantId, - object_type: "grant", - request_id: traceContext.request_id, - scenario_id: traceContext.scenario_id, - status: "succeeded", - subject_id: subjectId, - subject_type: "subject", - trace_id: traceContext.trace_id, - }); - - // Issue access token - const token = await issueToken(grantId, subjectId, registeredClient.client_id, expiresAt, { - source: "grant_approval", + tokenIssuedEvent: (tokenId) => + buildTokenIssuedEventInput({ + clientId: registeredClient.client_id, + grant, + grantId, + subjectId, + tokenId, + traceContext, + }), traceContext, }); - await markPendingConsentApproved(deviceCode, { - aiTrainingConsented: ai_training_consented, - grantId, - subjectId, - tokenId: token, - }); - return { grant, token }; } +async function resumeApprovedGrant( + pending: PendingConsentRow, + subjectId: string +): Promise<{ grant: DbRow; package?: boolean; package_id?: string; token: string }> { + if (pending.subject_id !== subjectId || !isNonEmptyString(pending.grant_id) || !isNonEmptyString(pending.token_id)) { + const err: AuthError = new Error("Approved consent result is not available"); + err.code = "not_found"; + throw err; + } + const tokenInfo = await introspect(pending.token_id); + if (!tokenInfo.active) { + const err: AuthError = new Error("Approved consent result is not available"); + err.code = "not_found"; + throw err; + } + if (tokenInfo.pdpp_token_kind === "mcp_package" && tokenInfo.grant_package_id === pending.grant_id) { + const packageRow = normalizePackageRow(await getGrantPackageStore().getPackageById(pending.grant_id)); + if (!(packageRow && packageRow.status === "active" && packageRow.subject_id === subjectId)) { + const err: AuthError = new Error("Approved consent package is not available"); + err.code = "not_found"; + throw err; + } + const members = await getGrantPackageStore().listAllMembers(pending.grant_id); + return { + grant: buildConsentPackageGrant(pending.grant_id, members), + package: true, + package_id: pending.grant_id, + token: pending.token_id, + }; + } + if (tokenInfo.grant_id !== pending.grant_id) { + const err: AuthError = new Error("Approved consent token is not active"); + err.code = "not_found"; + throw err; + } + const row = isPostgresStorageBackend() + ? await pgOne<DbRow>( + `SELECT g.grant_id, g.grant_json::text AS grant_json + FROM grants g + WHERE g.grant_id = $1 AND g.subject_id = $2 AND g.status = 'active'`, + [pending.grant_id, subjectId] + ) + : getOne<DbRow>(referenceQueries.authGrantsGetForRevocation, [pending.grant_id]); + if (!row || row.status === "revoked" || !isNonEmptyString(row.grant_json)) { + const err: AuthError = new Error("Approved consent result is not available"); + err.code = "not_found"; + throw err; + } + const grant: unknown = JSON.parse(row.grant_json); + if (!isRecord(grant) || grant.grant_id !== pending.grant_id) { + throw bindingError("grant_invalid", "Approved consent grant is malformed"); + } + return { grant, token: pending.token_id }; +} + function buildGrantScopedDeviceExchangeError(code: string, message: string, row: DbRow | null = null): AuthError { const err: AuthError = new Error(message); err.code = code; @@ -5250,16 +7404,14 @@ async function buildGrantScopedDeviceTokenPayload(row: DbRow, clientId: string): if (tokenInfo.client_id !== clientId) { throw buildGrantScopedDeviceExchangeError("invalid_client", "Client token is not bound to this client_id", row); } - const exp = - typeof tokenInfo.exp === "number" && Number.isFinite(tokenInfo.exp) && tokenInfo.exp - ? tokenInfo.exp - : Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60; const payload: Record<string, unknown> = { access_token: row.token_id, - expires_in: Math.max(exp - Math.floor(Date.now() / 1000), 0), token_type: "Bearer", trace_context: getPersistedPendingTraceContext(row), }; + if (typeof tokenInfo.exp === "number" && Number.isFinite(tokenInfo.exp)) { + payload.expires_in = Math.max(tokenInfo.exp - Math.floor(Date.now() / 1000), 0); + } if (tokenInfo.pdpp_token_kind === "mcp_package") { payload.grant_package_id = tokenInfo.grant_package_id || row.grant_id || null; } else { @@ -5328,7 +7480,19 @@ function buildOAuthRefreshTokenError(code: string, message: string): AuthError { return err; } -async function issueOAuthRefreshToken({ +interface PreparedInitialOAuthRefreshToken { + readonly clientId: string; + readonly createdAt: string; + readonly expiresAt: string | null; + readonly familyId: string; + readonly grantId?: string; + readonly packageId?: string; + readonly refreshToken: string; + readonly refreshTokenHash: string; + readonly subjectId: string; +} + +function prepareInitialOAuthRefreshToken({ clientId, grantId, subjectId, @@ -5338,15 +7502,21 @@ async function issueOAuthRefreshToken({ grantId: string; subjectId: string; expiresAt?: string | null; -}): Promise<string> { +}): PreparedInitialOAuthRefreshToken { const refreshToken = generateOAuthRefreshToken(); - const refreshTokenHash = hashOAuthRefreshToken(refreshToken); - const createdAt = nowIso(); - await getRefreshTokenStore().insert({ clientId, createdAt, expiresAt, grantId, refreshTokenHash, subjectId }); - return refreshToken; + return { + clientId, + createdAt: nowIso(), + expiresAt, + familyId: generateId("rtf"), + grantId, + refreshToken, + refreshTokenHash: hashOAuthRefreshToken(refreshToken), + subjectId, + }; } -async function issueOAuthRefreshTokenForPackage({ +function prepareInitialOAuthRefreshTokenForPackage({ clientId, packageId, subjectId, @@ -5356,19 +7526,18 @@ async function issueOAuthRefreshTokenForPackage({ packageId: string; subjectId: string; expiresAt?: string | null; -}): Promise<string> { +}): PreparedInitialOAuthRefreshToken { const refreshToken = generateOAuthRefreshToken(); - const refreshTokenHash = hashOAuthRefreshToken(refreshToken); - const createdAt = nowIso(); - await getRefreshTokenStore().insertForPackage({ + return { clientId, - createdAt, + createdAt: nowIso(), expiresAt, + familyId: generateId("rtf"), packageId, - refreshTokenHash, + refreshToken, + refreshTokenHash: hashOAuthRefreshToken(refreshToken), subjectId, - }); - return refreshToken; + }; } // Grant-package row operations. One adapter per backend; the dialect SQL @@ -5463,8 +7632,13 @@ const postgresGrantPackageStore: GrantPackageStore = { `SELECT gm.package_id, gm.grant_id, gm.token_id, gm.source_json::text AS source_json, gm.status, gm.added_at, gm.revoked_at, g.status AS grant_status, g.grant_json::text AS grant_json, + g.grant_id AS persisted_grant_id, g.subject_id AS grant_subject_id, + g.client_id AS grant_client_id, g.access_mode AS grant_access_mode, + g.expires_at AS grant_expires_at, g.storage_binding_json::text AS storage_binding_json, - t.revoked AS token_revoked, t.expires_at AS token_expires_at + t.grant_id AS token_grant_id, t.subject_id AS token_subject_id, + t.client_id AS token_client_id, t.revoked AS token_revoked, + t.expires_at AS token_expires_at FROM grant_package_members gm JOIN grants g ON gm.grant_id = g.grant_id JOIN tokens t ON gm.token_id = t.token_id @@ -5479,7 +7653,7 @@ const postgresGrantPackageStore: GrantPackageStore = { await postgresQuery<GrantPackageMemberRow>( `SELECT gm.package_id, gm.grant_id, gm.source_json::text AS source_json, gm.status AS member_status, gm.added_at, gm.revoked_at AS member_revoked_at, - g.status AS grant_status + g.status AS grant_status, g.access_mode AS grant_access_mode FROM grant_package_members gm JOIN grants g ON gm.grant_id = g.grant_id WHERE gm.package_id = $1 @@ -5624,7 +7798,8 @@ const postgresOAuthCodeStore: OAuthCodeStore = { ), getByDeviceCode: (deviceCode) => pgOne<OAuthPendingCodeRow>( - `SELECT id, device_code, client_id, redirect_uri, state, status, expires_at + `SELECT id, device_code, client_id, redirect_uri, state, status, expires_at, + code AS issued_code, grant_id, package_id, token_id, issued_at, consumed_at FROM oauth_authorization_codes WHERE device_code = $1`, [deviceCode] @@ -5674,6 +7849,7 @@ const postgresOAuthCodeStore: OAuthCodeStore = { status = 'pending', code = NULL, grant_id = NULL, + package_id = NULL, token_id = NULL, created_at = excluded.created_at, expires_at = excluded.expires_at, @@ -5736,70 +7912,67 @@ function getOAuthCodeStore() { return isPostgresStorageBackend() ? postgresOAuthCodeStore : sqliteOAuthCodeStore; } -const postgresRefreshTokenStore: RefreshTokenStore = { - getByTokenHash: (refreshTokenHash) => - pgOne<RefreshTokenRow>( - `SELECT refresh_token_hash, client_id, grant_id, package_id, subject_id, status, - created_at, expires_at, last_used_at, revoked_at - FROM oauth_refresh_tokens - WHERE refresh_token_hash = $1`, - [refreshTokenHash] - ), - insert: ({ refreshTokenHash, clientId, grantId, subjectId, createdAt, expiresAt }) => - pgExec( - `INSERT INTO oauth_refresh_tokens( - refresh_token_hash, client_id, grant_id, subject_id, status, - created_at, expires_at, last_used_at, revoked_at - ) VALUES($1, $2, $3, $4, 'active', $5, $6, NULL, NULL)`, - [refreshTokenHash, clientId, grantId, subjectId, createdAt, expiresAt] - ), - insertForPackage: ({ refreshTokenHash, clientId, packageId, subjectId, createdAt, expiresAt }) => - pgExec( - `INSERT INTO oauth_refresh_tokens( - refresh_token_hash, client_id, grant_id, package_id, subject_id, status, - created_at, expires_at, last_used_at, revoked_at - ) VALUES($1, $2, NULL, $3, $4, 'active', $5, $6, NULL, NULL)`, - [refreshTokenHash, clientId, packageId, subjectId, createdAt, expiresAt] - ), - markUsed: ({ usedAt, refreshTokenHash }) => - pgExec( - `UPDATE oauth_refresh_tokens - SET last_used_at = $1 - WHERE refresh_token_hash = $2 AND status = 'active'`, - [usedAt, refreshTokenHash] - ), -}; - const sqliteRefreshTokenStore: RefreshTokenStore = { - getByTokenHash: (refreshTokenHash) => - getOne<RefreshTokenRow>(referenceQueries.authOauthRefreshTokensGetByToken, [refreshTokenHash]), - insert: ({ refreshTokenHash, clientId, grantId, subjectId, createdAt, expiresAt }) => + insert: ({ + refreshTokenHash, + familyId, + generation, + parentGeneration, + clientId, + grantId, + subjectId, + createdAt, + expiresAt, + }) => exec(referenceQueries.authOauthRefreshTokensInsert, [ refreshTokenHash, + familyId, + generation, + parentGeneration, clientId, grantId, subjectId, createdAt, expiresAt, ]), - insertForPackage: ({ refreshTokenHash, clientId, packageId, subjectId, createdAt, expiresAt }) => + insertForPackage: ({ + refreshTokenHash, + familyId, + generation, + parentGeneration, + clientId, + packageId, + subjectId, + createdAt, + expiresAt, + }) => exec( requireMutationQuery(referenceQueries.authOauthRefreshTokensInsertPackage, "authOauthRefreshTokensInsertPackage"), - [refreshTokenHash, clientId, packageId, subjectId, createdAt, expiresAt] + [refreshTokenHash, familyId, generation, parentGeneration, clientId, packageId, subjectId, createdAt, expiresAt] ), - markUsed: ({ usedAt, refreshTokenHash }) => - exec(referenceQueries.authOauthRefreshTokensMarkUsed, [usedAt, refreshTokenHash]), }; -function getRefreshTokenStore() { - return isPostgresStorageBackend() ? postgresRefreshTokenStore : sqliteRefreshTokenStore; -} - const postgresTokenStore: TokenStore = { getIntrospection: (token) => pgOne<TokenIntrospectionRow>( - `SELECT t.token_id, t.grant_id, t.package_id, t.subject_id, t.client_id, t.token_kind, t.expires_at, t.revoked, + `SELECT t.token_id, t.grant_id, t.package_id, t.refresh_family_id, + CASE + WHEN t.refresh_family_id IS NULL THEN NULL + ELSE EXISTS( + SELECT 1 + FROM oauth_refresh_tokens rt + WHERE rt.family_id = t.refresh_family_id + AND rt.status = 'active' + AND rt.revoked_at IS NULL + ) + END AS refresh_family_active, + t.subject_id, t.client_id, t.token_kind, t.expires_at, t.revoked, g.status AS grant_status, + g.grant_id AS persisted_grant_id, + g.subject_id AS grant_subject_id, + g.client_id AS grant_client_id, + g.access_mode AS grant_access_mode, + g.expires_at AS grant_expires_at, g.grant_json::text AS grant_json, g.trace_id, g.scenario_id, @@ -5807,6 +7980,9 @@ const postgresTokenStore: TokenStore = { gp.package_json::text AS package_json, gp.trace_id AS package_trace_id, gp.scenario_id AS package_scenario_id, + gp.package_id AS persisted_package_id, + gp.subject_id AS package_subject_id, + gp.client_id AS package_client_id, g.storage_binding_json::text AS storage_binding_json FROM tokens t LEFT JOIN grants g ON t.grant_id = g.grant_id @@ -5862,6 +8038,19 @@ async function issuePackageToken( expiresAt: string | null = null, meta: { traceContext?: TraceContext | null; source?: string } = {} ): Promise<string> { + const packageRow = await getGrantPackageStore().getPackageById(packageId); + const grantPackage = normalizePackageRow(packageRow); + if ( + !grantPackage || + grantPackage.package_id !== packageId || + grantPackage.subject_id !== subjectId || + grantPackage.client_id !== clientId + ) { + throw buildOAuthAuthorizationCodeError( + "invalid_grant", + "Grant package binding is invalid; fresh consent is required" + ); + } const tokenId = generateToken(); await getGrantPackageStore().insertPackageToken({ clientId, expiresAt, packageId, subjectId, tokenId }); @@ -5900,6 +8089,37 @@ function parsePackageJson(raw: unknown): Record<string, unknown> | null { } } +function requireCurrentPackageEnvelope(row: DbRow): Record<string, unknown> | null { + const envelope = parsePackageJson(row.package_json); + if ( + !( + envelope && + hasExactBindingKeys(envelope, [ + "approved_source_count", + "client", + "package_id", + "source_bounded_child_grants", + "subject", + "version", + ]) && + envelope.version === CURRENT_GRANT_PACKAGE_VERSION && + envelope.package_id === row.package_id && + envelope.source_bounded_child_grants === true && + Number.isInteger(envelope.approved_source_count) && + Number(envelope.approved_source_count) > 0 && + hasExactBindingKeys(envelope.client, ["client_display", "client_id", "registration_mode"]) && + isRecord(envelope.client) && + envelope.client.client_id === row.client_id && + hasExactBindingKeys(envelope.subject, ["id"]) && + isRecord(envelope.subject) && + envelope.subject.id === row.subject_id + ) + ) { + return null; + } + return envelope; +} + function normalizePackageRow(row: DbRow | null | undefined): GrantPackageNormalized | null { if ( !( @@ -5914,11 +8134,15 @@ function normalizePackageRow(row: DbRow | null | undefined): GrantPackageNormali ) { return null; } + const packageEnvelope = requireCurrentPackageEnvelope(row); + if (!packageEnvelope) { + return null; + } return { approved_at: row.approved_at, client_id: row.client_id, created_at: row.created_at, - package: parsePackageJson(row.package_json), + package: packageEnvelope, package_id: row.package_id, parent_package_id: row.parent_package_id || null, revoked_at: row.revoked_at || null, @@ -5985,16 +8209,22 @@ async function requireValidParentPackageLinkage( function describePackageMemberSource( grant: DbRow, - connectionId: string | null = null, metadata: Record<string, unknown> | null = null ): Record<string, unknown> | null { const source = describeGrantSource(grant); if (!source) { return null; } + const instanceIds = Array.from( + new Set( + (Array.isArray(grant.streams) ? grant.streams : []).flatMap((stream) => + isRecord(stream) && Array.isArray(stream.instance_ids) ? stream.instance_ids.filter(isNonEmptyString) : [] + ) + ) + ); return { ...source, - ...(isNonEmptyString(connectionId) ? { connection_id: connectionId } : {}), + ...(instanceIds.length === 1 ? { connection_id: instanceIds[0] } : {}), ...(metadata?.display_name ? { display_name: metadata.display_name } : {}), ...(metadata?.connector_display_name ? { connector_display_name: metadata.connector_display_name } : {}), }; @@ -6044,22 +8274,18 @@ async function persistChildGrantForPackage({ request, registeredClient, subjectId, - sourceBinding, storageBinding, - manifest, resolvedStreams, traceContext, }: { request: PendingRequest; registeredClient: RegisteredClient; subjectId: string; - sourceBinding: SourceBinding; storageBinding: StorageBinding; - manifest: DbRow; - resolvedStreams: StreamSelection[]; + resolvedStreams: ResolvedGrantStream[]; traceContext: TraceContext; }): Promise<{ grant: GrantEnvelope; token: string; expiresAt: string | null }> { - const { client, selection } = request; + const { selection } = request; // Hosted MCP packages never carry ai_training; reject if a client tries. if (selection.purpose_code === "https://pdpp.dev/purpose/ai_training") { @@ -6074,28 +8300,23 @@ async function persistChildGrantForPackage({ const expiresAt = selection.access_mode === "single_use" ? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() : null; - const persistedSource = describeSourceBinding(sourceBinding); const persistedStorageBinding = normalizeStorageBinding(storageBinding); + const snapshot = readRetainedSourceDeclarationSnapshot(request); - const grant: GrantEnvelope = { - access_mode: selection.access_mode, - client: { - client_id: registeredClient.client_id, - registration_mode: registeredClient.registration_mode || "pre_registered_public", - ...(client.client_display ? { client_display: client.client_display } : {}), - }, - expires_at: expiresAt, - grant_id: grantId, - issued_at: issuedAt, - manifest_version: requireManifestVersion(manifest), - purpose_code: selection.purpose_code, - purpose_description: selection.purpose_description, + const grant = materializeCoreResolvedGrant({ + accessMode: selection.access_mode, + clientId: registeredClient.client_id, + expiresAt, + grantId, + issuedAt, + purposeCode: selection.purpose_code, + purposeDescription: selection.purpose_description, + resolvedStreams, retention: selection.retention, - source: persistedSource, - streams: resolvedStreams, - subject: { id: subjectId }, - version: "0.1.0", - }; + selectionPreset: selection.selection_preset, + snapshot: snapshot as unknown as import("./core-source-authorization.ts").RetainedCoreConsentSnapshot, + subjectId, + }) as unknown as GrantEnvelope; await getGrantPackageStore().insertChildGrant({ accessMode: selection.access_mode, @@ -6119,6 +8340,7 @@ async function persistChildGrantForPackage({ purpose_code: selection.purpose_code, retention: selection.retention ?? null, source: describeGrantSource(grant), + ...buildResolvedSnapshotEvidence(request, resolvedStreams), stream_names: resolvedStreams.map((stream) => stream.name), }, event_type: "grant.issued", @@ -6162,7 +8384,6 @@ export async function createHostedMcpGrantPackage({ clientId, authorizationDetails, storageBindings = [], - connectionIds = [], sourceMetadata = [], subjectId = "owner_local", opts = {}, @@ -6200,7 +8421,7 @@ export async function createHostedMcpGrantPackage({ package_id: packageId, source_bounded_child_grants: true, subject: { id: subjectId }, - version: "reference.mcp_package.v1", + version: CURRENT_GRANT_PACKAGE_VERSION, }; await getGrantPackageStore().insertPackage({ @@ -6222,7 +8443,7 @@ export async function createHostedMcpGrantPackage({ token: string; }[] = []; await forEachSequential(authorizationDetails, async (detail, index) => { - const request = normalizePendingGrantRequest({ authorization_details: [detail], client_id: clientId }, opts); + const request = await normalizePendingGrantRequest({ authorization_details: [detail], client_id: clientId }, opts); const selectedStorageBinding = normalizeStorageBinding(storageBindings[index]); if (selectedStorageBinding) { request.storage_binding = selectedStorageBinding; @@ -6234,20 +8455,19 @@ export async function createHostedMcpGrantPackage({ request.source_binding = describeSourceBinding(sourceBinding); request.storage_binding = normalizeStorageBinding(storageBinding); const manifest = await requireGrantManifestForBindings(sourceBinding, storageBinding, opts); - request.manifest_version = requireManifestVersion(manifest); - const resolvedStreams = resolveGrantSelection(request.selection, manifest); + await retainSourceDeclarationSnapshot(request, sourceBinding, storageBinding, manifest, opts); + const resolvedStreams = await resolvePendingRequestForApproval(request, sourceBinding, storageBinding, subjectId); const { grant, token } = await persistChildGrantForPackage({ - manifest, registeredClient: childRegisteredClient, request, resolvedStreams, - sourceBinding, storageBinding, subjectId, traceContext, }); - const connectionId = isNonEmptyString(connectionIds[index]) ? connectionIds[index] : null; - const source = describePackageMemberSource(grant, connectionId, sourceMetadata[index]); + const grantedInstanceIds = Array.from(new Set(grant.streams.flatMap((stream) => stream.instance_ids ?? []))); + const connectionId = grantedInstanceIds.length === 1 ? (grantedInstanceIds[0] ?? null) : null; + const source = describePackageMemberSource(grant, sourceMetadata[index]); const addedAt = nowIso(); await getGrantPackageStore().insertPackageMember({ addedAt, @@ -6335,6 +8555,18 @@ export async function getGrantPackageAccess(packageId: unknown): Promise<Record< } catch { return; } + const grantSubjectId = isRecord(grantState.grant.subject) ? grantState.grant.subject.id : null; + const grantClientId = isRecord(grantState.grant.client) ? grantState.grant.client.client_id : null; + if ( + grantSubjectId !== grantPackage.subject_id || + grantClientId !== grantPackage.client_id || + row.grant_subject_id !== grantPackage.subject_id || + row.grant_client_id !== grantPackage.client_id || + row.token_subject_id !== grantPackage.subject_id || + row.token_client_id !== grantPackage.client_id + ) { + return; + } const persistedSource = await normalizePersistedPackageMemberSource( parsePackageJson(row.source_json) || describeGrantSource(grantState.grant), { ownerSubjectId: grantPackage.subject_id } @@ -6413,7 +8645,7 @@ export async function listGrantPackagesForOwner( params.push(limit + 1); const limitPlaceholder = `$${params.length}`; ({ rows } = await postgresQuery<GrantPackageListRow>( - `SELECT gp.package_id, gp.subject_id, gp.client_id, gp.status, + `SELECT gp.package_id, gp.subject_id, gp.client_id, gp.status, gp.package_json::text AS package_json, gp.parent_package_id, gp.trace_id, gp.scenario_id, gp.created_at, gp.approved_at, gp.revoked_at, (SELECT COUNT(*) FROM grant_package_members gpm WHERE gpm.package_id = gp.package_id) AS member_count @@ -6793,18 +9025,59 @@ export async function revokeGrantPackage( }; } -export async function issueOAuthAuthorizationCodeForPackageDeviceCode( +type OAuthAuthorizationCodeBinding = + | { grantId: string; kind: "grant"; token: string } + | { kind: "package"; packageId: string; token: string }; + +function authorizationCodeDelivery(row: OAuthPendingCodeRow): Record<string, unknown> { + return { + client_id: row.client_id, + code: row.issued_code, + expires_at: row.expires_at, + redirect_uri: row.redirect_uri, + state: row.state || null, + }; +} + +function issuedCodeMatchesBinding(row: OAuthPendingCodeRow, binding: OAuthAuthorizationCodeBinding): boolean { + if (row.token_id !== binding.token) { + return false; + } + return binding.kind === "package" + ? row.package_id === binding.packageId && row.grant_id === null + : row.grant_id === binding.grantId && row.package_id === null; +} + +function recoverIssuedOAuthAuthorizationCode( + row: OAuthPendingCodeRow | null, + binding: OAuthAuthorizationCodeBinding +): Record<string, unknown> | null { + if (!row) { + return null; + } + if ( + row.status !== "issued" || + row.consumed_at || + isExpired(row) || + !isNonEmptyString(row.issued_code) || + !issuedCodeMatchesBinding(row, binding) + ) { + throw buildOAuthAuthorizationCodeError("invalid_grant", "OAuth authorization code delivery is not recoverable"); + } + return authorizationCodeDelivery(row); +} + +async function issueOrRecoverOAuthAuthorizationCode( deviceCode: unknown, - { packageId, token }: { packageId: string; token: string } + binding: OAuthAuthorizationCodeBinding ): Promise<Record<string, unknown> | null> { if (!isNonEmptyString(deviceCode)) { return null; } const oauthCodeStore = getOAuthCodeStore(); const row = await oauthCodeStore.getByDeviceCode(deviceCode); - if (row?.status !== "pending") { - return null; + return recoverIssuedOAuthAuthorizationCode(row, binding); } if (isExpired(row)) { await oauthCodeStore.markExpiredByDeviceCode(deviceCode); @@ -6814,15 +9087,45 @@ export async function issueOAuthAuthorizationCodeForPackageDeviceCode( const code = generateId("oacode"); const issuedAt = nowIso(); const expiresAt = expiresInIso(300); - await oauthCodeStore.issueForPackageDeviceCode({ code, deviceCode, expiresAt, issuedAt, packageId, token }); - - return { - client_id: row.client_id, - code, + const updated = + binding.kind === "package" + ? await oauthCodeStore.issueForPackageDeviceCode({ + code, + deviceCode, + expiresAt, + issuedAt, + packageId: binding.packageId, + token: binding.token, + }) + : await oauthCodeStore.issueForDeviceCode({ + code, + deviceCode, + expiresAt, + grantId: binding.grantId, + issuedAt, + token: binding.token, + }); + if (!updated.changes) { + return recoverIssuedOAuthAuthorizationCode(await oauthCodeStore.getByDeviceCode(deviceCode), binding); + } + return authorizationCodeDelivery({ + ...row, + consumed_at: null, expires_at: expiresAt, - redirect_uri: row.redirect_uri, - state: row.state || null, - }; + grant_id: binding.kind === "grant" ? binding.grantId : null, + issued_at: issuedAt, + issued_code: code, + package_id: binding.kind === "package" ? binding.packageId : null, + status: "issued", + token_id: binding.token, + }); +} + +export async function issueOAuthAuthorizationCodeForPackageDeviceCode( + deviceCode: unknown, + { packageId, token }: { packageId: string; token: string } +): Promise<Record<string, unknown> | null> { + return await issueOrRecoverOAuthAuthorizationCode(deviceCode, { kind: "package", packageId, token }); } export async function stageOAuthAuthorizationCodeRequest({ @@ -6892,32 +9195,7 @@ export async function issueOAuthAuthorizationCodeForDeviceCode( deviceCode: unknown, { grantId, token }: { grantId: string; token: string } ): Promise<Record<string, unknown> | null> { - if (!isNonEmptyString(deviceCode)) { - return null; - } - const oauthCodeStore = getOAuthCodeStore(); - const row = await oauthCodeStore.getByDeviceCode(deviceCode); - - if (row?.status !== "pending") { - return null; - } - if (isExpired(row)) { - await oauthCodeStore.markExpiredByDeviceCode(deviceCode); - throw buildOAuthAuthorizationCodeError("invalid_request", "OAuth authorization request has expired"); - } - - const code = generateId("oacode"); - const issuedAt = nowIso(); - const expiresAt = expiresInIso(300); - await oauthCodeStore.issueForDeviceCode({ code, deviceCode, expiresAt, grantId, issuedAt, token }); - - return { - client_id: row.client_id, - code, - expires_at: expiresAt, - redirect_uri: row.redirect_uri, - state: row.state || null, - }; + return await issueOrRecoverOAuthAuthorizationCode(deviceCode, { grantId, kind: "grant", token }); } function requireOAuthAuthorizationCodeExchangeInput(input: { @@ -6973,11 +9251,161 @@ function requireRedeemableOAuthAuthorizationCode( return row; } -async function addOAuthRefreshTokenToAuthorizationResponse( - response: Record<string, unknown>, +function prepareOAuthRefreshTokenForAuthorizationCode( + row: OAuthIssuedCodeRow, + clientId: string, + tokenInfo: TokenIntrospectionResult & { subject_id: string } +): PreparedInitialOAuthRefreshToken { + const expiresAt = typeof tokenInfo.exp === "number" ? new Date(tokenInfo.exp * 1000).toISOString() : null; + if (isNonEmptyString(row.package_id)) { + return prepareInitialOAuthRefreshTokenForPackage({ + clientId, + expiresAt, + packageId: row.package_id, + subjectId: tokenInfo.subject_id, + }); + } + if (!isNonEmptyString(row.grant_id)) { + throw buildOAuthAuthorizationCodeError("invalid_grant", "Authorization code is missing its grant binding"); + } + return prepareInitialOAuthRefreshToken({ + clientId, + expiresAt, + grantId: row.grant_id, + subjectId: tokenInfo.subject_id, + }); +} + +async function authorizationCodeBindingSupportsRefresh( + row: OAuthIssuedCodeRow, + tokenInfo: TokenIntrospectionResult +): Promise<boolean> { + if (isNonEmptyString(row.grant_id)) { + return isRecord(tokenInfo.grant) && tokenInfo.grant.access_mode === "continuous"; + } + if (!isNonEmptyString(row.package_id)) { + return false; + } + const members = await getGrantPackageStore().listAllMembers(row.package_id); + return members.length > 0 && members.every((member) => member.grant_access_mode === "continuous"); +} + +async function consumeOAuthAuthorizationCodeAtomically({ + code, + consumedAt, + refresh, + tokenId, +}: { + code: string; + consumedAt: string; + refresh: PreparedInitialOAuthRefreshToken | null; + tokenId: string; +}): Promise<void> { + if (isPostgresStorageBackend()) { + await withPostgresTransaction(async (client) => { + const consumed = await client.query( + `UPDATE oauth_authorization_codes + SET status = 'consumed', consumed_at = $1 + WHERE code = $2 AND status = 'issued' AND consumed_at IS NULL`, + [consumedAt, code] + ); + if (consumed.rowCount !== 1) { + throw buildOAuthAuthorizationCodeError("invalid_grant", "Authorization code is invalid or already used"); + } + if (!refresh) { + return; + } + const accessTokenExpiresAt = refreshAccessTokenExpiresAt(refresh.createdAt, refresh.expiresAt); + await client.query( + `INSERT INTO oauth_refresh_tokens( + refresh_token_hash, family_id, generation, parent_generation, client_id, + grant_id, package_id, subject_id, status, created_at, expires_at, + last_used_at, superseded_at, revoked_at + ) VALUES($1, $2, 0, NULL, $3, $4, $5, $6, 'active', $7, $8, NULL, NULL, NULL)`, + [ + refresh.refreshTokenHash, + refresh.familyId, + refresh.clientId, + refresh.grantId ?? null, + refresh.packageId ?? null, + refresh.subjectId, + refresh.createdAt, + refresh.expiresAt, + ] + ); + const linked = await client.query( + `UPDATE tokens + SET refresh_family_id = $1, expires_at = $2 + WHERE token_id = $3 AND refresh_family_id IS NULL`, + [refresh.familyId, accessTokenExpiresAt, tokenId] + ); + if (linked.rowCount !== 1) { + throw buildOAuthAuthorizationCodeError("invalid_grant", "Authorization code access token linkage failed"); + } + }); + return; + } + + writeTransaction(() => { + const consumed = exec(referenceQueries.authOauthAuthorizationCodesConsumeCode, [consumedAt, code]); + if (!consumed.changes) { + throw buildOAuthAuthorizationCodeError("invalid_grant", "Authorization code is invalid or already used"); + } + if (!refresh) { + return; + } + const accessTokenExpiresAt = refreshAccessTokenExpiresAt(refresh.createdAt, refresh.expiresAt); + if (refresh.packageId) { + sqliteRefreshTokenStore.insertForPackage({ + clientId: refresh.clientId, + createdAt: refresh.createdAt, + expiresAt: refresh.expiresAt, + familyId: refresh.familyId, + generation: 0, + packageId: refresh.packageId, + parentGeneration: null, + refreshTokenHash: refresh.refreshTokenHash, + subjectId: refresh.subjectId, + }); + const linked = exec(referenceQueries.authTokensLinkRefreshFamily, [ + refresh.familyId, + accessTokenExpiresAt, + tokenId, + ]); + if (linked.changes !== 1) { + throw buildOAuthAuthorizationCodeError("invalid_grant", "Authorization code access token linkage failed"); + } + return; + } + if (!refresh.grantId) { + throw buildOAuthAuthorizationCodeError("invalid_grant", "Authorization code is missing its grant binding"); + } + sqliteRefreshTokenStore.insert({ + clientId: refresh.clientId, + createdAt: refresh.createdAt, + expiresAt: refresh.expiresAt, + familyId: refresh.familyId, + generation: 0, + grantId: refresh.grantId, + parentGeneration: null, + refreshTokenHash: refresh.refreshTokenHash, + subjectId: refresh.subjectId, + }); + const linked = exec(referenceQueries.authTokensLinkRefreshFamily, [ + refresh.familyId, + accessTokenExpiresAt, + tokenId, + ]); + if (linked.changes !== 1) { + throw buildOAuthAuthorizationCodeError("invalid_grant", "Authorization code access token linkage failed"); + } + }); +} + +async function requireOAuthAuthorizationCodeTokenInfo( row: OAuthIssuedCodeRow, clientId: string -): Promise<void> { +): Promise<TokenIntrospectionResult & { subject_id: string }> { const tokenInfo = await introspect(row.token_id); const tokenMatches = row.package_id ? tokenInfo.grant_package_id === row.package_id && tokenInfo.pdpp_token_kind === "mcp_package" @@ -6988,121 +9416,588 @@ async function addOAuthRefreshTokenToAuthorizationResponse( if (!isNonEmptyString(tokenInfo.subject_id)) { throw buildOAuthAuthorizationCodeError("invalid_grant", "Issued grant token is missing its subject binding"); } - const expiresAt = typeof tokenInfo.exp === "number" ? new Date(tokenInfo.exp * 1000).toISOString() : null; + return tokenInfo as TokenIntrospectionResult & { subject_id: string }; +} + +export async function exchangeOAuthAuthorizationCode({ + code, + clientId, + redirectUri, + codeVerifier, + baseUrl = null, + issuerBase = null, +}: { + code: unknown; + clientId: unknown; + redirectUri: unknown; + codeVerifier: unknown; + baseUrl?: string | null; + issuerBase?: string | null; +}): Promise<Record<string, unknown>> { + const normalized = requireOAuthAuthorizationCodeExchangeInput({ clientId, code, codeVerifier, redirectUri }); + const oauthCodeStore = getOAuthCodeStore(); + const row = requireRedeemableOAuthAuthorizationCode( + await oauthCodeStore.getByCode(normalized.code), + normalized.clientId, + normalized.redirectUri, + normalized.codeVerifier + ); + const registeredClient = await resolveOAuthClient(normalized.clientId, { + ...(baseUrl ? { baseUrl } : {}), + ...(issuerBase ? { issuerBase } : {}), + }); + if (!registeredClient) { + throw buildOAuthAuthorizationCodeError("invalid_client", "Unknown client_id"); + } + + const response: Record<string, unknown> = { + access_token: row.token_id, + token_type: "Bearer", + ...(row.package_id ? { grant_package_id: row.package_id } : { grant_id: row.grant_id }), + }; + + const tokenInfo = await requireOAuthAuthorizationCodeTokenInfo(row, normalized.clientId); + if (row.grant_id) { + response.authorization_details = [buildGrantedAuthorizationDetail(tokenInfo.grant)]; + } + const refresh = + clientSupportsOAuthRefreshToken(registeredClient) && (await authorizationCodeBindingSupportsRefresh(row, tokenInfo)) + ? prepareOAuthRefreshTokenForAuthorizationCode(row, normalized.clientId, tokenInfo) + : null; + await consumeOAuthAuthorizationCodeAtomically({ + code: normalized.code, + consumedAt: nowIso(), + refresh, + tokenId: row.token_id, + }); + if (refresh) { + response.refresh_token = refresh.refreshToken; + response.access_token_expires_at = refreshAccessTokenExpiresAt(refresh.createdAt, refresh.expiresAt); + } else if (typeof tokenInfo.exp === "number") { + response.access_token_expires_at = new Date(tokenInfo.exp * 1000).toISOString(); + } + + return response; +} + +type RefreshRotationOutcome = + | { kind: "invalid" } + | { kind: "reused" } + | { accessToken: string; accessTokenExpiresAt: string; kind: "rotated"; row: RefreshTokenRow }; + +function isCurrentRefreshFamilyRow(row: RefreshTokenRow | null): row is RefreshTokenRow { + return !!( + row && + isNonEmptyString(row.family_id) && + Number.isInteger(row.generation) && + row.generation >= 0 && + ((row.generation === 0 && row.parent_generation === null) || + (row.generation > 0 && row.parent_generation === row.generation - 1)) + ); +} + +function refreshRowMatchesClientAndLifetime(row: RefreshTokenRow, clientId: string): boolean { + if (row.client_id !== clientId) { + return false; + } + if (!row.expires_at) { + return true; + } + const expiresAt = Date.parse(row.expires_at); + return Number.isFinite(expiresAt) && expiresAt > Date.now(); +} + +function hasRefreshAuthorizationBinding(row: RefreshTokenRow): boolean { + return isNonEmptyString(row.package_id) !== isNonEmptyString(row.grant_id); +} + +function refreshGrantUnavailable(message: string): AuthError { + return buildOAuthRefreshTokenError("invalid_grant", message); +} + +function refreshTokenIssuedEvent({ + grantId, + packageId, + persistedGrant, + row, + scenarioId, + tokenId, + traceId, +}: { + grantId?: string; + packageId?: string; + persistedGrant?: DbRow; + row: RefreshTokenRow; + scenarioId?: string | null; + tokenId: string; + traceId?: string | null; +}): SpineEventInput { + return { + actor_id: "pdpp_as", + actor_type: "authorization_server", + client_id: row.client_id, + data: { + issuance_path: "oauth_refresh_token", + refresh_family_id: row.family_id, + ...(packageId ? { grant_package_id: packageId } : {}), + ...(persistedGrant ? { source: describeGrantSource(persistedGrant) } : {}), + token_kind: packageId ? "mcp_package" : "client", + }, + event_type: "token.issued", + ...(grantId ? { grant_id: grantId } : {}), + object_id: tokenId, + object_type: "token", + ...(scenarioId ? { scenario_id: scenarioId } : {}), + status: "succeeded", + subject_id: row.subject_id, + subject_type: "subject", + token_id: tokenId, + ...(traceId ? { trace_id: traceId } : {}), + }; +} + +interface RefreshAuthorizationRow { + client_id: string; + status: string; + subject_id: string; +} + +function refreshAuthorizationRowMatches( + candidate: RefreshAuthorizationRow | null | undefined, + refreshRow: RefreshTokenRow +): candidate is RefreshAuthorizationRow { + return !!( + candidate && + candidate.status === "active" && + candidate.client_id === refreshRow.client_id && + candidate.subject_id === refreshRow.subject_id + ); +} + +function requireRefreshGrantRow( + candidate: GrantIssuanceRow | null | undefined, + refreshRow: RefreshTokenRow +): GrantIssuanceRow { + if (!refreshAuthorizationRowMatches(candidate, refreshRow)) { + throw refreshGrantUnavailable("Refresh token grant is no longer active"); + } + return candidate; +} + +function requireRefreshPackageRow( + candidate: GrantPackageListRow | null | undefined, + refreshRow: RefreshTokenRow +): GrantPackageListRow { + if (!refreshAuthorizationRowMatches(candidate, refreshRow)) { + throw refreshGrantUnavailable("Refresh token grant package is no longer active"); + } + return candidate; +} + +function requireRefreshGrantAvailableForConsumption(grantRow: GrantIssuanceRow): void { + if (grantRow.access_mode !== "single_use") { + return; + } + if (grantRow.consumed) { + throw refreshGrantUnavailable("Refresh token grant has already been consumed"); + } +} + +async function issuePostgresRefreshGrantAccessToken( + client: PostgresTransactionClient, + row: RefreshTokenRow, + grantId: string, + tokenId: string, + accessTokenExpiresAt: string +): Promise<string> { + const grantResult = await client.query<GrantIssuanceRow>( + `SELECT grant_id AS persisted_grant_id, subject_id AS grant_subject_id, + client_id AS grant_client_id, access_mode AS grant_access_mode, + expires_at AS grant_expires_at, + access_mode, client_id, consumed, status, subject_id, trace_id, scenario_id, + grant_json::text AS grant_json, + storage_binding_json::text AS storage_binding_json + FROM grants + WHERE grant_id = $1 + FOR UPDATE`, + [grantId] + ); + const grantRow = requireRefreshGrantRow(grantResult.rows[0], row); + requireRefreshGrantAvailableForConsumption(grantRow); + if (grantRow.access_mode === "single_use") { + await client.query("UPDATE grants SET consumed = TRUE WHERE grant_id = $1", [grantId]); + } + const persistedGrant = requirePersistedGrantState(grantRow).grant; + await client.query( + `INSERT INTO tokens( + token_id, grant_id, refresh_family_id, subject_id, client_id, token_kind, expires_at + ) VALUES($1, $2, $3, $4, $5, 'client', $6)`, + [tokenId, grantId, row.family_id, row.subject_id, row.client_id, accessTokenExpiresAt] + ); + await postgresEmitSpineEventInTransaction( + client, + refreshTokenIssuedEvent({ + grantId, + persistedGrant, + row, + scenarioId: grantRow.scenario_id, + tokenId, + traceId: grantRow.trace_id, + }) + ); + return tokenId; +} + +async function issuePostgresRefreshPackageAccessToken( + client: PostgresTransactionClient, + row: RefreshTokenRow, + packageId: string, + tokenId: string, + accessTokenExpiresAt: string +): Promise<string> { + const packageResult = await client.query<GrantPackageListRow>( + `SELECT package_id, subject_id, client_id, status, package_json::text AS package_json, + parent_package_id, trace_id, scenario_id, created_at, approved_at, revoked_at + FROM grant_packages + WHERE package_id = $1 + FOR UPDATE`, + [packageId] + ); + const packageRow = requireRefreshPackageRow(packageResult.rows[0], row); + const members = await client.query<{ access_mode: string }>( + `SELECT g.access_mode + FROM grant_package_members gm + JOIN grants g ON g.grant_id = gm.grant_id + WHERE gm.package_id = $1`, + [packageId] + ); + if (members.rows.length === 0 || members.rows.some((member) => member.access_mode !== "continuous")) { + throw refreshGrantUnavailable("Refresh token package contains a non-continuous grant"); + } + await client.query( + `INSERT INTO tokens( + token_id, grant_id, package_id, refresh_family_id, subject_id, client_id, token_kind, expires_at + ) VALUES($1, NULL, $2, $3, $4, $5, 'mcp_package', $6)`, + [tokenId, packageId, row.family_id, row.subject_id, row.client_id, accessTokenExpiresAt] + ); + await postgresEmitSpineEventInTransaction( + client, + refreshTokenIssuedEvent({ + packageId, + row, + scenarioId: packageRow.scenario_id, + tokenId, + traceId: packageRow.trace_id, + }) + ); + return tokenId; +} + +async function issuePostgresOAuthRefreshAccessToken( + client: PostgresTransactionClient, + row: RefreshTokenRow, + accessTokenExpiresAt: string +): Promise<string> { + const tokenId = generateToken(); + if (isNonEmptyString(row.grant_id)) { + return await issuePostgresRefreshGrantAccessToken(client, row, row.grant_id, tokenId, accessTokenExpiresAt); + } if (isNonEmptyString(row.package_id)) { - response.refresh_token = await issueOAuthRefreshTokenForPackage({ - clientId, - expiresAt, - packageId: row.package_id, - subjectId: tokenInfo.subject_id, - }); - return; + return await issuePostgresRefreshPackageAccessToken(client, row, row.package_id, tokenId, accessTokenExpiresAt); } - if (!isNonEmptyString(row.grant_id)) { - throw buildOAuthAuthorizationCodeError("invalid_grant", "Authorization code is missing its grant binding"); - } - response.refresh_token = await issueOAuthRefreshToken({ - clientId, - expiresAt, - grantId: row.grant_id, - subjectId: tokenInfo.subject_id, - }); + throw refreshGrantUnavailable("Refresh token has no authorization binding"); } -export async function exchangeOAuthAuthorizationCode({ - code, - clientId, - redirectUri, - codeVerifier, - baseUrl = null, - issuerBase = null, -}: { - code: unknown; - clientId: unknown; - redirectUri: unknown; - codeVerifier: unknown; - baseUrl?: string | null; - issuerBase?: string | null; -}): Promise<Record<string, unknown>> { - const normalized = requireOAuthAuthorizationCodeExchangeInput({ clientId, code, codeVerifier, redirectUri }); - const oauthCodeStore = getOAuthCodeStore(); - const row = requireRedeemableOAuthAuthorizationCode( - await oauthCodeStore.getByCode(normalized.code), - normalized.clientId, - normalized.redirectUri, - normalized.codeVerifier +function issueSqliteRefreshGrantAccessToken( + row: RefreshTokenRow, + grantId: string, + tokenId: string, + accessTokenExpiresAt: string +): string { + const grantRow = requireRefreshGrantRow( + getOne<GrantIssuanceRow>(referenceQueries.authGrantsGetForIssuance, [grantId]), + row ); - const registeredClient = await resolveOAuthClient(normalized.clientId, { - ...(baseUrl ? { baseUrl } : {}), - ...(issuerBase ? { issuerBase } : {}), - }); - if (!registeredClient) { - throw buildOAuthAuthorizationCodeError("invalid_client", "Unknown client_id"); + requireRefreshGrantAvailableForConsumption(grantRow); + if (grantRow.access_mode === "single_use") { + exec(referenceQueries.authGrantsMarkConsumed, [grantId]); } + const persistedGrant = requirePersistedGrantState(grantRow).grant; + exec(referenceQueries.authTokensInsertRefreshClient, [ + tokenId, + grantId, + row.family_id, + row.subject_id, + row.client_id, + accessTokenExpiresAt, + ]); + emitRawSpineEvent( + refreshTokenIssuedEvent({ + grantId, + persistedGrant, + row, + scenarioId: grantRow.scenario_id, + tokenId, + traceId: grantRow.trace_id, + }) + ); + return tokenId; +} - const consumedAt = nowIso(); - const updated = await oauthCodeStore.consumeCode({ code: normalized.code, consumedAt }); +function issueSqliteRefreshPackageAccessToken( + row: RefreshTokenRow, + packageId: string, + tokenId: string, + accessTokenExpiresAt: string +): string { + const packageRow = requireRefreshPackageRow( + getOne<GrantPackageListRow>(referenceQueries.authGrantPackagesGetById, [packageId]), + row + ); + const members = allowUnboundedReadAcknowledged<GrantPackageMemberRow>( + referenceQueries.authGrantPackageMembersListAllByPackage, + [packageId] + ); + if (members.length === 0 || members.some((member) => member.grant_access_mode !== "continuous")) { + throw refreshGrantUnavailable("Refresh token package contains a non-continuous grant"); + } + exec(referenceQueries.authTokensInsertRefreshMcpPackage, [ + tokenId, + packageId, + row.family_id, + row.subject_id, + row.client_id, + accessTokenExpiresAt, + ]); + emitRawSpineEvent( + refreshTokenIssuedEvent({ + packageId, + row, + scenarioId: packageRow.scenario_id, + tokenId, + traceId: packageRow.trace_id, + }) + ); + return tokenId; +} - if (!updated.changes) { - throw buildOAuthAuthorizationCodeError("invalid_grant", "Authorization code is invalid or already used"); +function issueSqliteOAuthRefreshAccessToken(row: RefreshTokenRow, accessTokenExpiresAt: string): string { + const tokenId = generateToken(); + if (isNonEmptyString(row.grant_id)) { + return issueSqliteRefreshGrantAccessToken(row, row.grant_id, tokenId, accessTokenExpiresAt); + } + if (isNonEmptyString(row.package_id)) { + return issueSqliteRefreshPackageAccessToken(row, row.package_id, tokenId, accessTokenExpiresAt); } + throw refreshGrantUnavailable("Refresh token has no authorization binding"); +} - const response: Record<string, unknown> = { - access_token: row.token_id, - token_type: "Bearer", - ...(row.package_id ? { grant_package_id: row.package_id } : { grant_id: row.grant_id }), - }; +async function rotatePostgresOAuthRefreshToken({ + clientId, + refreshTokenHash, + rotatedAt, + successorTokenHash, +}: { + clientId: string; + refreshTokenHash: string; + rotatedAt: string; + successorTokenHash: string; +}): Promise<RefreshRotationOutcome> { + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The transaction keeps family validation, bearer issuance, event persistence, and rotation in one rollback boundary. + return await withPostgresTransaction(async (client) => { + const familyResult = await client.query<{ family_id: string | null }>( + `SELECT family_id + FROM oauth_refresh_tokens + WHERE refresh_token_hash = $1`, + [refreshTokenHash] + ); + const familyId = familyResult.rows[0]?.family_id; + if (!isNonEmptyString(familyId)) { + return { kind: "invalid" }; + } + await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [familyId]); + const result = await client.query<RefreshTokenRow>( + `SELECT refresh_token_hash, family_id, generation, parent_generation, client_id, + grant_id, package_id, subject_id, status, created_at, expires_at, + last_used_at, superseded_at, revoked_at + FROM oauth_refresh_tokens + WHERE refresh_token_hash = $1 + FOR UPDATE`, + [refreshTokenHash] + ); + const row = result.rows[0] ?? null; + if ( + !( + isCurrentRefreshFamilyRow(row) && + refreshRowMatchesClientAndLifetime(row, clientId) && + hasRefreshAuthorizationBinding(row) + ) + ) { + return { kind: "invalid" }; + } + if (row.family_id !== familyId) { + return { kind: "invalid" }; + } + if (row.status === "superseded") { + await client.query( + `UPDATE oauth_refresh_tokens + SET status = 'revoked', revoked_at = $1 + WHERE family_id = $2 AND status <> 'revoked'`, + [rotatedAt, row.family_id] + ); + await client.query("UPDATE tokens SET revoked = TRUE WHERE refresh_family_id = $1 AND revoked = FALSE", [ + row.family_id, + ]); + return { kind: "reused" }; + } + if (row.status !== "active" || row.revoked_at) { + return { kind: "invalid" }; + } - if (clientSupportsOAuthRefreshToken(registeredClient)) { - await addOAuthRefreshTokenToAuthorizationResponse(response, row, normalized.clientId); - } + let accessToken: string; + const accessTokenExpiresAt = refreshAccessTokenExpiresAt(rotatedAt, row.expires_at); + try { + accessToken = await issuePostgresOAuthRefreshAccessToken(client, row, accessTokenExpiresAt); + } catch (error: unknown) { + if (!(isAuthError(error) && error.code === "invalid_grant")) { + throw error; + } + await client.query( + `UPDATE oauth_refresh_tokens + SET status = 'revoked', revoked_at = $1 + WHERE family_id = $2 AND status <> 'revoked'`, + [rotatedAt, row.family_id] + ); + await client.query("UPDATE tokens SET revoked = TRUE WHERE refresh_family_id = $1 AND revoked = FALSE", [ + row.family_id, + ]); + return { kind: "invalid" }; + } - return response; + const superseded = await client.query( + `UPDATE oauth_refresh_tokens + SET status = 'superseded', last_used_at = $1, superseded_at = $1 + WHERE refresh_token_hash = $2 AND status = 'active'`, + [rotatedAt, refreshTokenHash] + ); + if (superseded.rowCount !== 1) { + throw buildOAuthRefreshTokenError("invalid_grant", "Refresh token rotation lost its active generation"); + } + await client.query( + `INSERT INTO oauth_refresh_tokens( + refresh_token_hash, family_id, generation, parent_generation, client_id, + grant_id, package_id, subject_id, status, created_at, expires_at, + last_used_at, superseded_at, revoked_at + ) VALUES($1, $2, $3, $4, $5, $6, $7, $8, 'active', $9, $10, NULL, NULL, NULL)`, + [ + successorTokenHash, + row.family_id, + row.generation + 1, + row.generation, + row.client_id, + row.grant_id, + row.package_id, + row.subject_id, + rotatedAt, + row.expires_at, + ] + ); + return { accessToken, accessTokenExpiresAt, kind: "rotated", row }; + }); } -function requireActiveOAuthRefreshToken(row: RefreshTokenRow | null, clientId: string): RefreshTokenRow { - if (row?.status !== "active" || row.revoked_at) { - throw buildOAuthRefreshTokenError("invalid_grant", "Refresh token is invalid"); - } - if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) { - throw buildOAuthRefreshTokenError("invalid_grant", "Refresh token has expired"); - } - if (row.client_id !== clientId) { - throw buildOAuthRefreshTokenError("invalid_grant", "Refresh token client_id mismatch"); - } - return row; -} +function rotateSqliteOAuthRefreshToken({ + clientId, + refreshTokenHash, + rotatedAt, + successorTokenHash, +}: { + clientId: string; + refreshTokenHash: string; + rotatedAt: string; + successorTokenHash: string; +}): RefreshRotationOutcome { + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The transaction keeps family validation, bearer issuance, event persistence, and rotation in one rollback boundary. + return writeTransaction(() => { + const row = getOne<RefreshTokenRow>(referenceQueries.authOauthRefreshTokensGetByToken, [refreshTokenHash]); + if ( + !( + isCurrentRefreshFamilyRow(row) && + refreshRowMatchesClientAndLifetime(row, clientId) && + hasRefreshAuthorizationBinding(row) + ) + ) { + return { kind: "invalid" }; + } + if (row.status === "superseded") { + exec(referenceQueries.authOauthRefreshTokensRevokeFamily, [rotatedAt, row.family_id]); + exec(referenceQueries.authTokensRevokeByRefreshFamily, [row.family_id]); + return { kind: "reused" }; + } + if (row.status !== "active" || row.revoked_at) { + return { kind: "invalid" }; + } -async function issueAccessTokenForOAuthRefresh(row: RefreshTokenRow): Promise<string> { - try { - if (isNonEmptyString(row.package_id)) { - const grantPackage = await getGrantPackageAccess(row.package_id); - if (!grantPackage) { - const err: AuthError = new Error("Grant package is no longer active"); - err.code = "package_revoked"; - throw err; + let accessToken: string; + const accessTokenExpiresAt = refreshAccessTokenExpiresAt(rotatedAt, row.expires_at); + try { + accessToken = issueSqliteOAuthRefreshAccessToken(row, accessTokenExpiresAt); + } catch (error: unknown) { + if (!(isAuthError(error) && error.code === "invalid_grant")) { + throw error; } - return issuePackageToken(row.package_id, row.subject_id, row.client_id, row.expires_at || null, { - source: "oauth_refresh_token", - }); + exec(referenceQueries.authOauthRefreshTokensRevokeFamily, [rotatedAt, row.family_id]); + exec(referenceQueries.authTokensRevokeByRefreshFamily, [row.family_id]); + return { kind: "invalid" }; } - if (isNonEmptyString(row.grant_id)) { - return issueToken(row.grant_id, row.subject_id, row.client_id, row.expires_at || null, { - source: "oauth_refresh_token", - }); + + const superseded = exec(referenceQueries.authOauthRefreshTokensSupersedeActive, [ + rotatedAt, + rotatedAt, + refreshTokenHash, + ]); + if (superseded.changes !== 1) { + throw buildOAuthRefreshTokenError("invalid_grant", "Refresh token rotation lost its active generation"); } - throw buildOAuthRefreshTokenError("invalid_grant", "Refresh token has no grant binding"); - } catch (err: unknown) { - if (!isAuthError(err)) { - throw err; + const successor = { + clientId: row.client_id, + createdAt: rotatedAt, + expiresAt: row.expires_at, + familyId: row.family_id, + generation: row.generation + 1, + parentGeneration: row.generation, + refreshTokenHash: successorTokenHash, + subjectId: row.subject_id, + }; + if (isNonEmptyString(row.package_id)) { + sqliteRefreshTokenStore.insertForPackage({ ...successor, packageId: row.package_id }); + } else if (isNonEmptyString(row.grant_id)) { + sqliteRefreshTokenStore.insert({ ...successor, grantId: row.grant_id }); + } else { + throw buildOAuthRefreshTokenError("invalid_grant", "Refresh token has no authorization binding"); } - const code = - isNonEmptyString(err.code) && - ["grant_revoked", "grant_invalid", "grant_consumed", "package_revoked", "not_found"].includes(err.code) - ? "invalid_grant" - : err.code || "invalid_grant"; - throw buildOAuthRefreshTokenError(code, err.message || "Refresh token grant is no longer valid"); - } + return { accessToken, accessTokenExpiresAt, kind: "rotated", row }; + }); +} + +async function rotateOAuthRefreshToken(input: { + clientId: string; + refreshTokenHash: string; + rotatedAt: string; + successorTokenHash: string; +}): Promise<RefreshRotationOutcome> { + return await (isPostgresStorageBackend() + ? rotatePostgresOAuthRefreshToken(input) + : Promise.resolve(rotateSqliteOAuthRefreshToken(input))); +} + +function reusedOAuthRefreshTokenError(): AuthError { + const error = buildOAuthRefreshTokenError( + "invalid_grant", + "Refresh token reuse revoked its family; fresh authorization is required" + ); + error.fresh_authorization_required = true; + return error; } export async function exchangeOAuthRefreshToken({ @@ -7119,118 +10014,355 @@ export async function exchangeOAuthRefreshToken({ throw buildOAuthRefreshTokenError("invalid_request", "client_id is required"); } - const refreshTokenHash = hashOAuthRefreshToken(refreshToken); - const refreshTokenStore = getRefreshTokenStore(); - const row = requireActiveOAuthRefreshToken(await refreshTokenStore.getByTokenHash(refreshTokenHash), clientId); - const registeredClient = await getRegisteredClient(clientId); if (!(registeredClient && clientSupportsOAuthRefreshToken(registeredClient))) { throw buildOAuthRefreshTokenError("invalid_grant", "Client is not registered for refresh_token"); } - const accessToken = await issueAccessTokenForOAuthRefresh(row); - - const usedAt = nowIso(); - await refreshTokenStore.markUsed({ refreshTokenHash, usedAt }); + const refreshTokenHash = hashOAuthRefreshToken(refreshToken); + const successorToken = generateOAuthRefreshToken(); + const rotatedAt = nowIso(); + const outcome = await rotateOAuthRefreshToken({ + clientId, + refreshTokenHash, + rotatedAt, + successorTokenHash: hashOAuthRefreshToken(successorToken), + }); + if (outcome.kind === "reused") { + throw reusedOAuthRefreshTokenError(); + } + if (outcome.kind !== "rotated") { + throw buildOAuthRefreshTokenError("invalid_grant", "Refresh token is invalid"); + } return { - access_token: accessToken, - refresh_token: refreshToken, + access_token: outcome.accessToken, + access_token_expires_at: outcome.accessTokenExpiresAt, + refresh_token: successorToken, token_type: "Bearer", - ...(row.package_id ? { grant_package_id: row.package_id } : { grant_id: row.grant_id }), + ...(outcome.row.package_id ? { grant_package_id: outcome.row.package_id } : { grant_id: outcome.row.grant_id }), }; } -/** - * Consent exchange-code store. - * - * The HTML branch of `POST /consent/approve` SHALL NOT render the live client - * bearer to the browser; instead it mints a single-use opaque exchange code, - * stores `{ code -> { grantId, token, grant, expiresAt, consumed } }` here, - * and tells the caller to redeem the code at `POST /consent/exchange`. - * - * In-memory by design: the reference is single-process, the codes are - * short-lived, and a code that survives a process restart would weaken the - * "short-lived single-use ticket" property. See - * openspec/changes/harden-consent-token-handoff/design.md. - */ -const consentExchangeCodes = new Map<string, ConsentExchangeEntry>(); const CONSENT_EXCHANGE_CODE_TTL_MS = 5 * 60 * 1000; +const CONSENT_EXCHANGE_CODE_RE = /^cex_[0-9a-f]{64}$/; -function pruneExpiredConsentExchangeCodes(now = Date.now()): void { - for (const [code, entry] of consentExchangeCodes) { - if (entry.consumed || entry.expiresAt <= now) { - consentExchangeCodes.delete(code); - } +function buildConsentPackageGrant( + packageId: string, + memberRows: readonly GrantPackageMemberRow[] +): Record<string, unknown> { + return { + child_grants: memberRows.map((row) => ({ + grant_id: row.grant_id, + source: parsePackageJson(row.source_json), + })), + grant_id: packageId, + package: true, + package_id: packageId, + }; +} + +function consentExchangeTokenIsActive(row: ConsentExchangeRow): boolean { + return ( + !row.token_revoked && + (!row.token_expires_at || new Date(row.token_expires_at).getTime() > Date.now()) && + Boolean(row.grant_id) !== Boolean(row.package_id) + ); +} + +function parseConsentExchangeCodeCredential(code: string): { codeHash: string } | null { + if (!CONSENT_EXCHANGE_CODE_RE.test(code)) { + return null; } + return { codeHash: base64UrlSha256(code) }; } -export function createConsentExchangeCode({ +export async function createConsentExchangeCode({ grantId, token, grant, ttlMs = CONSENT_EXCHANGE_CODE_TTL_MS, + recoveryProof, }: { grantId: string; token: string; grant: Record<string, unknown>; + recoveryProof?: string; ttlMs?: number; -}): string { - if (!(grantId && token && grant)) { - throw new Error("createConsentExchangeCode requires grantId, token, and grant"); - } - pruneExpiredConsentExchangeCodes(); - const code = `cex_${randomBytes(32).toString("hex")}`; - consentExchangeCodes.set(code, { - consumed: false, - expiresAt: Date.now() + ttlMs, - grant, - grantId, - token, - }); - return code; +}): Promise<string> { + if (!(grantId && token && isRecord(grant) && (grant.grant_id === grantId || grant.package_id === grantId))) { + throw new Error("createConsentExchangeCode requires a matching grantId, token, and grant"); + } + const tokenInfo = await introspect(token); + const isPackage = tokenInfo.pdpp_token_kind === "mcp_package"; + if (!tokenInfo.active || (isPackage ? tokenInfo.grant_package_id !== grantId : tokenInfo.grant_id !== grantId)) { + throw new Error("createConsentExchangeCode requires an active token bound to the approved result"); + } + const codeSecret = `cex_${randomBytes(32).toString("hex")}`; + const codeHash = base64UrlSha256(codeSecret); + const proofHash = + typeof recoveryProof === "string" && recoveryProof.length > 0 ? base64UrlSha256(recoveryProof) : null; + const createdAt = nowIso(); + const expiresAt = new Date(Date.now() + ttlMs).toISOString(); + if (isPostgresStorageBackend()) { + await withPostgresTransaction(async (client) => { + await client.query( + `UPDATE consent_exchange_codes + SET redeemed_at = $1, + expires_at = $1 + WHERE token_id = $2 + AND redeemed_at IS NULL`, + [createdAt, token] + ); + await client.query( + `INSERT INTO consent_exchange_codes( + code_hash, proof_hash, token_id, created_at, expires_at, redeemed_at + ) VALUES($1, $2, $3, $4, $5, NULL)`, + [codeHash, proofHash, token, createdAt, expiresAt] + ); + }); + } else { + transaction(() => { + exec(referenceQueries.authConsentExchangeCodesInvalidateOutstandingByToken as MutationQuery, [ + createdAt, + createdAt, + token, + ]); + exec(referenceQueries.authConsentExchangeCodesInsert, [codeHash, proofHash, token, createdAt, expiresAt]); + }); + } + return codeSecret; +} + +function consentExchangeProofMatches(row: ConsentExchangeRow, proofHash: string | null): boolean { + if (!row.proof_hash) { + return true; + } + return proofHash === row.proof_hash; +} + +function consentExchangeRedeemedReason(row: ConsentExchangeRow, proofHash: string | null): "consumed" | null { + if (!row.redeemed_at) { + return null; + } + return row.proof_hash && consentExchangeProofMatches(row, proofHash) ? null : "consumed"; +} + +async function loadPostgresConsentExchangeGrant( + client: PostgresTransactionClient, + row: ConsentExchangeRow +): Promise< + | { ok: false; reason: "revoked" | "unknown" } + | { grant: Record<string, unknown>; grantId?: string; ok: true; packageId?: string } +> { + if (row.grant_id) { + const grantResult = await client.query<DbRow>( + `SELECT grant_id, grant_json::text AS grant_json + FROM grants + WHERE grant_id = $1 AND status = 'active' + FOR SHARE`, + [row.grant_id] + ); + const grantRow = grantResult.rows[0] || null; + if (!(grantRow && isNonEmptyString(grantRow.grant_json))) { + return { ok: false, reason: "revoked" }; + } + const parsed: unknown = JSON.parse(grantRow.grant_json); + if (!isRecord(parsed) || parsed.grant_id !== row.grant_id) { + return { ok: false, reason: "unknown" }; + } + return { grant: parsed, grantId: row.grant_id, ok: true }; + } + const packageResult = await client.query<DbRow>( + `SELECT package_id + FROM grant_packages + WHERE package_id = $1 AND status = 'active' + FOR SHARE`, + [row.package_id] + ); + if (!packageResult.rows[0]) { + return { ok: false, reason: "revoked" }; + } + const members = await client.query<GrantPackageMemberRow>( + `SELECT gm.grant_id, gm.source_json::text AS source_json + FROM grant_package_members gm + WHERE gm.package_id = $1 + ORDER BY gm.added_at, gm.grant_id`, + [row.package_id] + ); + if (!row.package_id) { + return { ok: false, reason: "unknown" }; + } + return { + grant: buildConsentPackageGrant(row.package_id, members.rows), + ok: true, + packageId: row.package_id, + }; } -export function consumeConsentExchangeCode(code: unknown): { +export async function consumeConsentExchangeCode( + code: unknown, + recoveryProof?: unknown +): Promise<{ ok: boolean; reason?: string; grantId?: string; + packageId?: string; token?: string; grant?: Record<string, unknown>; -} { +}> { if (typeof code !== "string" || code.length === 0) { return { ok: false, reason: "unknown" }; } - const entry = consentExchangeCodes.get(code); - if (!entry) { + const parsedCredential = parseConsentExchangeCodeCredential(code); + if (!parsedCredential) { return { ok: false, reason: "unknown" }; } - if (entry.consumed) { - return { ok: false, reason: "consumed" }; - } - if (entry.expiresAt <= Date.now()) { - consentExchangeCodes.delete(code); - return { ok: false, reason: "expired" }; - } - entry.consumed = true; - consentExchangeCodes.delete(code); - return { - grant: entry.grant, - grantId: entry.grantId, - ok: true, - token: entry.token, - }; -} - -/** Test-only escape hatch: clear the in-memory exchange-code store. */ -export function _resetConsentExchangeCodes(): void { - consentExchangeCodes.clear(); + const { codeHash } = parsedCredential; + const proofHash = + typeof recoveryProof === "string" && recoveryProof.length > 0 ? base64UrlSha256(recoveryProof) : null; + const redeemedAt = nowIso(); + const result = isPostgresStorageBackend() + ? await withPostgresTransaction( + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This transaction deliberately keeps row lock, active-authority validation, envelope reconstruction, and first-redemption transition in one auditable atomic unit. + async (client) => { + const selected = await client.query<ConsentExchangeRow>( + `SELECT c.code_hash, c.proof_hash, c.token_id, c.created_at, c.expires_at, + c.redeemed_at, t.grant_id, t.package_id, + t.revoked AS token_revoked, t.expires_at AS token_expires_at + FROM consent_exchange_codes c + JOIN tokens t ON t.token_id = c.token_id + WHERE c.code_hash = $1 + FOR UPDATE OF c, t`, + [codeHash] + ); + const row = selected.rows[0] || null; + if (!row) { + return { ok: false as const, reason: "unknown" }; + } + if (!consentExchangeProofMatches(row, proofHash)) { + return { ok: false as const, reason: row.redeemed_at ? "consumed" : "unknown" }; + } + if (new Date(row.expires_at).getTime() <= Date.now()) { + return { ok: false as const, reason: "expired" }; + } + if (!consentExchangeTokenIsActive(row)) { + return { ok: false as const, reason: "revoked" }; + } + const redeemedReason = consentExchangeRedeemedReason(row, proofHash); + if (redeemedReason) { + return { ok: false as const, reason: redeemedReason }; + } + const loaded = await loadPostgresConsentExchangeGrant(client, row); + if (!loaded.ok) { + return { ok: false as const, reason: loaded.reason }; + } + if (!row.redeemed_at) { + await client.query( + "UPDATE consent_exchange_codes SET redeemed_at = $1 WHERE code_hash = $2 AND redeemed_at IS NULL", + [redeemedAt, codeHash] + ); + } + return { + grant: loaded.grant, + ok: true as const, + token: row.token_id, + ...(loaded.packageId ? { packageId: loaded.packageId } : { grantId: loaded.grantId as string }), + }; + } + ) + : transaction( + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SQLite mirrors the PostgreSQL atomic unit so authority validation and first redemption cannot be split across transactions. + () => { + const row = getOne<ConsentExchangeRow>(referenceQueries.authConsentExchangeCodesGetForRedemption, [codeHash]); + if (!row) { + return { ok: false as const, reason: "unknown" }; + } + if (!consentExchangeProofMatches(row, proofHash)) { + return { ok: false as const, reason: row.redeemed_at ? "consumed" : "unknown" }; + } + if (new Date(row.expires_at).getTime() <= Date.now()) { + return { ok: false as const, reason: "expired" }; + } + if (!consentExchangeTokenIsActive(row)) { + return { ok: false as const, reason: "revoked" }; + } + const redeemedReason = consentExchangeRedeemedReason(row, proofHash); + if (redeemedReason) { + return { ok: false as const, reason: redeemedReason }; + } + let grant: Record<string, unknown>; + let grantId: string | undefined; + let packageId: string | undefined; + if (row.grant_id) { + const grantRow = getOne<DbRow>(referenceQueries.authGrantsGetForRevocation, [row.grant_id]); + if (!(grantRow && grantRow.status === "active" && isNonEmptyString(grantRow.grant_json))) { + return { ok: false as const, reason: "revoked" }; + } + const parsed: unknown = JSON.parse(grantRow.grant_json); + if (!isRecord(parsed) || parsed.grant_id !== row.grant_id) { + return { ok: false as const, reason: "unknown" }; + } + grant = parsed; + grantId = row.grant_id; + } else { + const packageRow = sqliteGrantPackageStore.getPackageById(row.package_id || "") as DbRow | null; + if (!(packageRow && packageRow.status === "active" && row.package_id)) { + return { ok: false as const, reason: "revoked" }; + } + const members = sqliteGrantPackageStore.listAllMembers(row.package_id) as readonly GrantPackageMemberRow[]; + packageId = row.package_id; + grant = buildConsentPackageGrant(packageId, members); + } + if (!row.redeemed_at) { + exec(referenceQueries.authConsentExchangeCodesMarkRedeemed, [redeemedAt, codeHash]); + } + return { + grant, + ok: true as const, + token: row.token_id, + ...(packageId ? { packageId } : { grantId: grantId as string }), + }; + } + ); + return result; } /** * Deny and clear a pending grant request */ -export async function denyGrant(deviceCode: string): Promise<boolean> { +function buildPendingConsentDeniedEventContext( + request: unknown, + userCode: string | null | undefined +): { clientId: string; data: Record<string, unknown> } { + if (isStagedBatchRequest(request)) { + if (!isNonEmptyString(request.client.client_id) || request.entries.length === 0) { + throw bindingError("invalid_request", "Batch pending request is malformed"); + } + const sources = request.entries.map((entry) => { + const slice = asSingleEntryRequestSlice(request, entry); + requireStructuredPendingRequestShape(slice); + return describeSourceBinding(requireStructuredPendingRequestBindings(slice).sourceBinding); + }); + return { + clientId: request.client.client_id, + data: { sources, user_code: userCode }, + }; + } + requireStructuredPendingRequestShape(request); + return { + clientId: request.client.client_id, + data: { + source: describeSourceBinding(requireStructuredPendingRequestBindings(request).sourceBinding), + user_code: userCode, + }, + }; +} + +export async function denyGrant( + deviceCode: string, + opts: { beforeCasHook?: () => void | Promise<void>; faultHook?: AuthorizationDecisionFaultHook } = {} +): Promise<boolean> { const pending = await getPendingConsentRow(deviceCode); if (pending?.status !== "pending") { return false; @@ -7239,21 +10371,14 @@ export async function denyGrant(deviceCode: string): Promise<boolean> { await markPendingConsentExpired(deviceCode); return false; } - await markPendingConsentDenied(deviceCode); - const request: unknown = JSON.parse(pending.params_json); - requireStructuredPendingRequestShape(request); - const traceContext = requirePersistedPendingTraceContext(pending); - request.trace_context = traceContext; - const { sourceBinding } = requireStructuredPendingRequestBindings(request); - await emitSpineEvent({ + const traceContext = requirePersistedPendingTraceContext(pending); + const deniedContext = buildPendingConsentDeniedEventContext(request, pending.user_code); + const deniedEvent: AuthSpineEventInput = { actor_id: pending.subject_id || "owner_local", actor_type: "subject", - client_id: request.client?.client_id || null, - data: { - source: describeSourceBinding(sourceBinding), - user_code: pending.user_code, - }, + client_id: deniedContext.clientId, + data: deniedContext.data, event_type: "consent.denied", object_id: deviceCode, object_type: "pending_consent", @@ -7261,6 +10386,13 @@ export async function denyGrant(deviceCode: string): Promise<boolean> { scenario_id: traceContext.scenario_id, status: "denied", trace_id: traceContext.trace_id, + }; + await opts.beforeCasHook?.(); + await getPendingConsentStore().markDeniedAtomically({ + deniedAt: nowIso(), + deviceCode, + event: deniedEvent, + ...(opts.faultHook ? { faultHook: opts.faultHook } : {}), }); return true; @@ -7412,7 +10544,8 @@ export async function getOwnerDeviceAuthorizationByUserCode( */ export async function approveOwnerDeviceAuthorization( userCode: unknown, - subjectId = "owner_local" + subjectId = "owner_local", + opts: { faultHook?: OwnerDeviceApprovalFaultHook } = {} ): Promise<Record<string, unknown>> { const pending = await getOwnerDeviceAuthRowByUserCode(userCode); if (!pending) { @@ -7420,90 +10553,49 @@ export async function approveOwnerDeviceAuthorization( err.code = "not_found"; throw err; } - if (pending.status !== "pending") { - throw attachOwnerDeviceTraceContext( - Object.assign(new Error("Owner device authorization is not available"), { - code: "not_found", + + const traceContext = ownerDeviceTraceContext(pending); + const token = generateToken(); + const tokenExpiresAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(); + let approved: OwnerDeviceAuthRow; + try { + approved = await getOwnerDeviceAuthStore().approveAtomically({ + clientId: pending.client_id, + consentApprovedEvent: buildOwnerDeviceConsentApprovedEvent({ + clientId: pending.client_id, + pending, + subjectId, + traceContext, }), - pending - ); - } - if (isExpired(pending)) { - await markOwnerDeviceAuthExpired(pending.device_code); - throw attachOwnerDeviceTraceContext( - Object.assign(new Error("Owner device authorization has expired"), { - code: "not_found", + deviceCode: pending.device_code, + expiresAt: tokenExpiresAt, + faultHook: opts.faultHook, + pendingSnapshot: pending, + subjectId, + tokenId: token, + tokenIssuedEvent: buildOwnerDeviceTokenIssuedEvent({ + clientId: pending.client_id, + pending, + subjectId, + token, + traceContext, }), - pending - ); - } - let registeredClient: RegisteredClient | null; - try { - registeredClient = await getRegisteredClient(pending.client_id); + }); } catch (err: unknown) { - if (isAuthError(err) && err.code === "invalid_client") { - throw attachOwnerDeviceTraceContext(err, pending); + if (isOwnerDeviceExpiredError(err)) { + await markOwnerDeviceAuthExpired(pending.device_code); } throw err; } - if (!registeredClient) { - const err: AuthError = new Error(`Unknown client_id: ${pending.client_id}`); - err.code = "invalid_client"; - throw attachOwnerDeviceTraceContext(err, pending); - } - try { - registeredClient = await bindDynamicClientToApprovingOwner(registeredClient, subjectId); - } catch (err: unknown) { - if (!isAuthError(err)) { - throw err; - } - throw attachOwnerDeviceTraceContext(err, pending); - } - - const traceContext = - isNonEmptyString(pending.trace_id) && isNonEmptyString(pending.request_id) - ? { - request_id: pending.request_id, - ...(isNonEmptyString(pending.scenario_id) ? { scenario_id: pending.scenario_id } : {}), - trace_id: pending.trace_id, - } - : null; - - await emitSpineEvent({ - actor_id: subjectId, - actor_type: "subject", - client_id: registeredClient.client_id, - data: { - issuance_path: "owner_device_flow", - user_code: pending.user_code, - }, - event_type: "consent.approved", - object_id: pending.device_code, - object_type: "owner_device_auth", - request_id: traceContext?.request_id || undefined, - scenario_id: traceContext?.scenario_id || undefined, - status: "succeeded", - subject_id: subjectId, - subject_type: "subject", - trace_id: traceContext?.trace_id || undefined, - }); - const token = await issueOwnerToken(subjectId, { - clientId: registeredClient.client_id, - traceContext, - userCode: pending.user_code, - }); - await markOwnerDeviceAuthApproved(pending.device_code, { subjectId, tokenId: token }); - - return { - access_token: token, - expires_in: 365 * 24 * 60 * 60, - subject_id: subjectId, - token_type: "Bearer", - }; + return ownerDeviceApprovalResponse(approved, subjectId); } -export async function denyOwnerDeviceAuthorization(userCode: unknown, subjectId = "owner_local"): Promise<void> { +export async function denyOwnerDeviceAuthorization( + userCode: unknown, + subjectId = "owner_local", + opts: { beforeCasHook?: () => void | Promise<void>; faultHook?: AuthorizationDecisionFaultHook } = {} +): Promise<void> { const pending = await getOwnerDeviceAuthRowByUserCode(userCode); if (!pending) { const err: AuthError = new Error("Unknown user code"); @@ -7537,8 +10629,7 @@ export async function denyOwnerDeviceAuthorization(userCode: unknown, subjectId } : null; - await markOwnerDeviceAuthDenied(pending.device_code); - await emitSpineEvent({ + const rejectedEvent: AuthSpineEventInput = { actor_id: subjectId, actor_type: "subject", client_id: pending.client_id, @@ -7559,7 +10650,21 @@ export async function denyOwnerDeviceAuthorization(userCode: unknown, subjectId subject_id: subjectId, subject_type: "subject", trace_id: traceContext?.trace_id || undefined, - }); + }; + await opts.beforeCasHook?.(); + try { + await getOwnerDeviceAuthStore().markDeniedAtomically({ + deniedAt: nowIso(), + deviceCode: pending.device_code, + event: rejectedEvent, + ...(opts.faultHook ? { faultHook: opts.faultHook } : {}), + }); + } catch (err: unknown) { + if (isAuthError(err) && err.code === "approval_conflict") { + throw attachOwnerDeviceTraceContext(err, pending); + } + throw err; + } } function ownerDeviceExchangeError(row: OwnerDeviceAuthRow, code: string, message: string): AuthError { @@ -7568,6 +10673,100 @@ function ownerDeviceExchangeError(row: OwnerDeviceAuthRow, code: string, message return attachOwnerDeviceTraceContext(err, row); } +function ownerDeviceApprovalResponse(row: OwnerDeviceAuthRow, fallbackSubjectId: string): Record<string, unknown> { + return { + access_token: row.token_id, + expires_in: 365 * 24 * 60 * 60, + subject_id: row.subject_id || fallbackSubjectId, + token_type: "Bearer", + }; +} + +function ownerDeviceTraceContext(row: OwnerDeviceAuthRow): TraceContext | null { + if (!(isNonEmptyString(row.trace_id) && isNonEmptyString(row.request_id))) { + return null; + } + return { + request_id: row.request_id, + ...(isNonEmptyString(row.scenario_id) ? { scenario_id: row.scenario_id } : {}), + trace_id: row.trace_id, + }; +} + +function ownerDeviceTraceEventFields( + traceContext: TraceContext | null +): Pick<AuthSpineEventInput, "request_id" | "scenario_id" | "trace_id"> { + return traceContext + ? { + request_id: traceContext.request_id, + scenario_id: traceContext.scenario_id, + trace_id: traceContext.trace_id, + } + : {}; +} + +function buildOwnerDeviceConsentApprovedEvent({ + clientId, + pending, + subjectId, + traceContext, +}: { + clientId: string; + pending: OwnerDeviceAuthRow; + subjectId: string; + traceContext: TraceContext | null; +}): AuthSpineEventInput { + return { + actor_id: subjectId, + actor_type: "subject", + client_id: clientId, + data: { + issuance_path: "owner_device_flow", + user_code: pending.user_code, + }, + event_type: "consent.approved", + object_id: pending.device_code, + object_type: "owner_device_auth", + status: "succeeded", + subject_id: subjectId, + subject_type: "subject", + ...ownerDeviceTraceEventFields(traceContext), + }; +} + +function buildOwnerDeviceTokenIssuedEvent({ + clientId, + pending, + subjectId, + token, + traceContext, +}: { + clientId: string; + pending: OwnerDeviceAuthRow; + subjectId: string; + token: string; + traceContext: TraceContext | null; +}): AuthSpineEventInput { + return { + actor_id: "pdpp_as", + actor_type: "authorization_server", + client_id: clientId, + data: { + issuance_path: "owner_device_flow", + token_kind: "owner", + user_code: pending.user_code, + }, + event_type: "token.issued", + object_id: token, + object_type: "token", + status: "succeeded", + subject_id: subjectId, + subject_type: "subject", + token_id: token, + ...ownerDeviceTraceEventFields(traceContext), + }; +} + async function requireOwnerDeviceClient(row: OwnerDeviceAuthRow, clientId: string): Promise<void> { try { const registeredClient = await getRegisteredClient(clientId); @@ -7671,7 +10870,11 @@ async function insertPostgresGrantToken( }: { clientId: string; expiresAt: string | null; grantId: string; subjectId: string } ): Promise<{ grantRow: GrantIssuanceRow; persistedGrant: DbRow; tokenId: string }> { const result = await client.query<GrantIssuanceRow>( - `SELECT access_mode, consumed, status, trace_id, scenario_id, + `SELECT grant_id AS persisted_grant_id, subject_id AS grant_subject_id, + client_id AS grant_client_id, access_mode AS grant_access_mode, + expires_at AS grant_expires_at, + grant_id, subject_id, client_id, access_mode, expires_at, + consumed, status, trace_id, scenario_id, grant_json::text AS grant_json, storage_binding_json::text AS storage_binding_json FROM grants @@ -7692,6 +10895,13 @@ async function insertPostgresGrantToken( err.code = row.status === "revoked" ? "grant_revoked" : "grant_invalid"; throw err; } + const persistedGrant = requirePersistedGrantState(row).grant; + requirePersistedGrantColumnBindings(persistedGrant, row, "grant_invalid", { + clientId, + expiresAt, + grantId, + subjectId, + }); if (row.access_mode === "single_use") { if (row.consumed) { const err: AuthError = new Error("Grant has already been consumed"); @@ -7708,11 +10918,55 @@ async function insertPostgresGrantToken( ); return { grantRow: row, - persistedGrant: requirePersistedGrantState(row).grant, + persistedGrant, tokenId, }; } +function insertSqliteGrantTokenInCurrentTransaction({ + clientId, + expiresAt, + grantId, + subjectId, +}: { + clientId: string; + expiresAt: string | null; + grantId: string; + subjectId: string; +}): { grantRow: GrantIssuanceRow; persistedGrant: DbRow; tokenId: string } { + const grantRow = getOne<GrantIssuanceRow>(referenceQueries.authGrantsGetForIssuance, [grantId]); + if (!grantRow) { + const err: AuthError = new Error(`Unknown grant: ${grantId}`); + err.code = "grant_invalid"; + throw err; + } + if (grantRow.status !== "active") { + const err: AuthError = new Error( + grantRow.status === "revoked" ? "Grant has been revoked" : `Grant is not active: ${grantRow.status}` + ); + err.code = grantRow.status === "revoked" ? "grant_revoked" : "grant_invalid"; + throw err; + } + const { grant: persistedGrant } = requirePersistedGrantState(grantRow); + requirePersistedGrantColumnBindings(persistedGrant, grantRow, "grant_invalid", { + clientId, + expiresAt, + grantId, + subjectId, + }); + if (grantRow.access_mode === "single_use") { + if (grantRow.consumed) { + const err: AuthError = new Error("Grant has already been consumed"); + err.code = "grant_consumed"; + throw err; + } + exec(referenceQueries.authGrantsMarkConsumed, [grantId]); + } + const tokenId = generateToken(); + exec(referenceQueries.authTokensInsertClient, [tokenId, grantId, subjectId, clientId, expiresAt]); + return { grantRow, persistedGrant, tokenId }; +} + export async function issueToken( grantId: string, subjectId: string, @@ -7754,35 +11008,13 @@ export async function issueToken( // synchronous function and wrap it; the public export stays `async` because // external callers `await issueToken(...)`. return transaction(() => { - const grantRow = getOne<GrantIssuanceRow>(referenceQueries.authGrantsGetForIssuance, [grantId]); - - if (!grantRow) { - const err: AuthError = new Error(`Unknown grant: ${grantId}`); - err.code = "grant_invalid"; - throw err; - } - - if (grantRow.status !== "active") { - const err: AuthError = new Error( - grantRow.status === "revoked" ? "Grant has been revoked" : `Grant is not active: ${grantRow.status}` - ); - err.code = grantRow.status === "revoked" ? "grant_revoked" : "grant_invalid"; - throw err; - } - - if (grantRow.access_mode === "single_use") { - if (grantRow.consumed) { - const err: AuthError = new Error("Grant has already been consumed"); - err.code = "grant_consumed"; - throw err; - } - exec(referenceQueries.authGrantsMarkConsumed, [grantId]); - } - - const tokenId = generateToken(); - exec(referenceQueries.authTokensInsertClient, [tokenId, grantId, subjectId, clientId, expiresAt]); + const { grantRow, persistedGrant, tokenId } = insertSqliteGrantTokenInCurrentTransaction({ + clientId, + expiresAt, + grantId, + subjectId, + }); - const { grant: persistedGrant } = requirePersistedGrantState(grantRow); // emitSpineEvent is sync internally; calling without await is fine // because the INSERT it triggers has completed before this returns. emitSpineEvent({ @@ -7889,23 +11121,53 @@ function inactiveInvalidGrantToken(row: TokenIntrospectionRow): TokenIntrospecti }; } -async function enrichClientTokenIntrospection( +function inactiveUnsupportedLegacyGrantToken(row: TokenIntrospectionRow): TokenIntrospectionResult { + return { + active: false, + client_id: row.client_id, + grant_id: row.grant_id, + inactive_reason: "authorization_state.unsupported_legacy_shape", + scenario_id: row.scenario_id, + subject_id: row.subject_id, + trace_id: row.trace_id, + }; +} + +function enrichPackageTokenIntrospection( + row: TokenIntrospectionRow, + result: TokenIntrospectionResult +): TokenIntrospectionResult { + const packageEnvelope = requireCurrentPackageEnvelope(row); + if ( + !packageEnvelope || + row.package_id !== row.persisted_package_id || + row.subject_id !== row.package_subject_id || + row.client_id !== row.package_client_id + ) { + return { + active: false, + client_id: row.client_id, + grant_package_id: row.package_id, + inactive_reason: "package_invalid", + scenario_id: row.package_scenario_id, + subject_id: row.subject_id, + trace_id: row.package_trace_id, + }; + } + result.grant_package_id = row.package_id; + result.client_id = row.client_id; + result.package = packageEnvelope; + result.trace_id = row.package_trace_id; + result.scenario_id = row.package_scenario_id; + return result; +} + +function enrichClientTokenIntrospection( row: TokenIntrospectionRow, result: TokenIntrospectionResult -): Promise<TokenIntrospectionResult> { +): TokenIntrospectionResult { try { const { grant: parsedGrant, storageBinding: grantStorageBinding } = requirePersistedGrantState(row); - try { - const manifest = await getManifestForStorageBinding(grantStorageBinding); - if (manifest) { - requireGrantContractAgainstManifest(parsedGrant, manifest); - } - } catch (err: unknown) { - if (isAuthError(err) && err.code === "grant_invalid") { - return inactiveInvalidGrantToken(row); - } - throw err; - } result.grant_id = row.grant_id; result.client_id = row.client_id; result.grant = parsedGrant; @@ -7914,6 +11176,9 @@ async function enrichClientTokenIntrospection( result.scenario_id = row.scenario_id; return result; } catch (err: unknown) { + if (isAuthError(err) && err.code === "authorization_state.unsupported_legacy_shape") { + return inactiveUnsupportedLegacyGrantToken(row); + } if (!isAuthError(err) || err.code !== "grant_invalid") { throw err; } @@ -7942,6 +11207,14 @@ export async function introspect(token: unknown): Promise<TokenIntrospectionResu }; } + if (row.refresh_family_id && !row.refresh_family_active) { + return { + active: false, + inactive_reason: "refresh_family_revoked", + ...getInactiveTokenBinding(row), + }; + } + // Check expiry if (row.expires_at && new Date(row.expires_at) < new Date()) { return { @@ -7978,21 +11251,19 @@ export async function introspect(token: unknown): Promise<TokenIntrospectionResu const result: TokenIntrospectionResult = { active: true, - exp: row.expires_at ? Math.floor(new Date(row.expires_at).getTime() / 1000) : null, pdpp_token_kind: row.token_kind, subject_id: row.subject_id, }; + if (row.expires_at) { + result.exp = Math.floor(new Date(row.expires_at).getTime() / 1000); + } if (row.token_kind === "owner" && row.client_id) { result.client_id = row.client_id; } if (row.token_kind === "mcp_package") { - result.grant_package_id = row.package_id; - result.client_id = row.client_id; - result.package = parsePackageJson(row.package_json); - result.trace_id = row.package_trace_id; - result.scenario_id = row.package_scenario_id; + return enrichPackageTokenIntrospection(row, result); } if (row.token_kind === "client") { @@ -8011,20 +11282,21 @@ interface GrantRevocationContext { trace_id?: string | null; } +const REVOCATION_INVALID_GRANT_ERROR_CODES = new Set<string | undefined>([ + "authorization_state.unsupported_legacy_shape", + "grant_invalid", +]); + async function requireRevocablePersistedGrant( row: GrantRevocationRow, grantId: string, context: GrantRevocationContext ): Promise<DbRow> { try { - const { grant, storageBinding } = requirePersistedGrantState(row); - const manifest = await getManifestForStorageBinding(storageBinding); - if (manifest) { - requireGrantContractAgainstManifest(grant, manifest); - } + const { grant } = requirePersistedGrantState(row); return grant; } catch (err: unknown) { - if (!(isAuthError(err) && err.code === "grant_invalid")) { + if (!(isAuthError(err) && REVOCATION_INVALID_GRANT_ERROR_CODES.has(err.code))) { throw err; } const sourceDescriptor = describePersistedGrantSource(row); @@ -8105,6 +11377,10 @@ export async function revokeGrant( const row0 = isPostgresStorageBackend() ? await pgOne<GrantRevocationRow>( `SELECT client_id, subject_id, trace_id, scenario_id, + grant_id AS persisted_grant_id, subject_id AS grant_subject_id, + client_id AS grant_client_id, access_mode AS grant_access_mode, + expires_at AS grant_expires_at, + grant_id, access_mode, expires_at, grant_json::text AS grant_json, storage_binding_json::text AS storage_binding_json FROM grants diff --git a/reference-implementation/server/backup-table-policy.ts b/reference-implementation/server/backup-table-policy.ts index 79a1e4895..c3b7207f4 100644 --- a/reference-implementation/server/backup-table-policy.ts +++ b/reference-implementation/server/backup-table-policy.ts @@ -13,6 +13,10 @@ export const BACKUP_TABLE_INVENTORY: Record<string, BackupTableInventoryEntry> = classification: "backup_required", reason: "Manual/import acquisition history and artifact linkage are owner state.", }, + agent_connect_attempts: { + classification: "backup_required", + reason: "Agent-connect handoff attempts and recovery bindings are durable authorization delivery state.", + }, blob_bindings: { classification: "backup_required", reason: "Binds durable blob bytes to records and JSON paths.", @@ -102,6 +106,10 @@ export const BACKUP_TABLE_INVENTORY: Record<string, BackupTableInventoryEntry> = classification: "backup_required", reason: "Connector catalog rows are required to interpret connections and records.", }, + consent_exchange_codes: { + classification: "ephemeral_crash_reconciled", + reason: "Consent exchange codes are short-lived single-use handoff state reconciled by expiry and redemption.", + }, controller_active_runs: { classification: "ephemeral_crash_reconciled", reason: "Active run rows represent in-flight work and must be reconciled by startup/runtime recovery.", diff --git a/reference-implementation/server/connection-identity.ts b/reference-implementation/server/connection-identity.ts index 4e3650d09..ebc458664 100644 --- a/reference-implementation/server/connection-identity.ts +++ b/reference-implementation/server/connection-identity.ts @@ -221,25 +221,24 @@ export async function listActiveBindingsForGrant({ * many connections the hint is ignored unless explicitly requested. * - `requestConnectionId`: canonical `connection_id` filter parsed from * the request (or its deprecated `connector_instance_id` alias). - * - `grantStreamConnectionId`: per-stream `connection_id` constraint - * from the grant scope. Absent constraint preserves fan-in. + * - `authorizedInstanceIds`: the closed grant stream's non-empty + * `instance_ids` authority. This is the upper bound for every client + * read; request-time selectors may only narrow it. * - * Returns `{ bindings: [...], warnings: [...] }`. Bindings are - * `{ connectorInstanceId, connectorId, displayName? }` ordered by - * created_at ASC. Empty array means no active binding addressable under - * the grant — callers should map that to `not_found` or - * `connection_not_found` per their surface. + * Returns `{ bindings: [...], warnings: [...] }`. The closed grant ids are + * the authority. Current instance rows may enrich display names, but cannot + * widen or revoke the set named by the grant. */ export async function resolveFanInBindings({ - ownerSubjectId, + ownerSubjectId: _ownerSubjectId, connectorId, - connectorInstanceIdHint = null, + connectorInstanceIdHint: _connectorInstanceIdHint = null, requestConnectionId = null, - grantStreamConnectionId = null, + authorizedInstanceIds, }: { + authorizedInstanceIds: readonly string[]; connectorId: string | null | undefined; connectorInstanceIdHint?: string | null; - grantStreamConnectionId?: string | null; ownerSubjectId: string | null | undefined; requestConnectionId?: string | null; }): Promise<{ @@ -251,21 +250,18 @@ export async function resolveFanInBindings({ return { bindings: [], warnings }; } - const active = await listActiveBindingsForGrant({ connectorId, ownerSubjectId }); - - // Honor grant-scope per-stream connection_id constraint first; absent - // constraint preserves fan-in across all active bindings. - let candidates = active; - if (grantStreamConnectionId) { - candidates = candidates.filter((row) => row.connectorInstanceId === grantStreamConnectionId); - if (candidates.length === 0) { - const err = new ConnectionNotFoundError( - `Grant scope connection_id '${grantStreamConnectionId}' is not currently active for connector '${connectorId}'.` - ); - throw err; - } + if (!Array.isArray(authorizedInstanceIds) || authorizedInstanceIds.length === 0) { + throw new ConnectionNotFoundError("The grant does not authorize any source instances for this stream."); } + let candidates = await Promise.all( + [...new Set(authorizedInstanceIds)].map(async (connectorInstanceId) => ({ + connectorId, + connectorInstanceId, + displayName: await lookupConnectionDisplayName(connectorInstanceId, connectorId), + })) + ); + // Narrow further by request-time `connection_id` (canonical or alias). if (requestConnectionId) { const narrowed = candidates.filter((row) => row.connectorInstanceId === requestConnectionId); @@ -278,25 +274,6 @@ export async function resolveFanInBindings({ candidates = narrowed; } - // Fallback to the previously-pinned single binding when no active rows - // are registered yet. Today the reference runtime pins the binding at - // ingest time via `ensureDefaultAccountConnection`, so this path mainly - // covers boot-time / freshly-issued grants whose default-account row - // has not yet materialized; the caller's storage layer continues to - // operate against `connectorInstanceIdHint` in that case. - if (candidates.length === 0 && connectorInstanceIdHint) { - return { - bindings: [ - { - connectorId, - connectorInstanceId: connectorInstanceIdHint, - displayName: null, - }, - ], - warnings, - }; - } - return { bindings: candidates.map((row) => ({ connectorId: row.connectorId, @@ -362,9 +339,9 @@ export async function listActiveOwnerBindingsForConnectors({ * Inputs: * - `ownerSubjectId`: owner subject backing the grant. * - `connectorId`: connector_id from the storage binding. - * - `grantStreamConnectionId`: per-stream `grant.streams[].connection_id` - * constraint. When set, the result is narrowed to that one binding - * (returns empty when the constraint is no longer active). + * - `authorizedInstanceIds`: per-stream `grant.streams[].instance_ids` + * authority. When present, these frozen ids are the result set; current + * instance rows may enrich display names but cannot revoke grant scope. * * Used by `GET /v1/schema` to advertise the discoverable set of connections * per stream so grant-authorized clients can call subsequent reads with an @@ -377,20 +354,30 @@ export async function listActiveOwnerBindingsForConnectors({ export async function listGrantedConnectionsForStream({ ownerSubjectId, connectorId, - grantStreamConnectionId = null, + authorizedInstanceIds = null, }: { + authorizedInstanceIds?: readonly string[] | null; ownerSubjectId: string | null | undefined; connectorId: string | null | undefined; - grantStreamConnectionId?: string | null; }): Promise<ConnectionWireBinding[]> { if (!(ownerSubjectId && connectorId)) { return []; } + if (authorizedInstanceIds) { + const identities = await Promise.all( + [...new Set(authorizedInstanceIds)].map((connectorInstanceId) => + resolveRecordIdentityForBinding(connectorInstanceId, connectorId) + ) + ); + return identities + .filter((identity): identity is { connectionId: string; displayName?: string } => identity !== null) + .map((identity) => ({ + connection_id: identity.connectionId, + ...(identity.displayName ? { display_name: identity.displayName } : {}), + })); + } const active = await listActiveBindingsForGrant({ connectorId, ownerSubjectId }); - const filtered = grantStreamConnectionId - ? active.filter((row) => row.connectorInstanceId === grantStreamConnectionId) - : active; - return filtered + return active .map((row) => projectBindingForWire({ connectorId: row.connectorId, @@ -416,19 +403,19 @@ export async function resolveRequestBindings({ connectorId, connectorInstanceIdHint = null, requestParams = {}, - grantStreamConnectionId = null, + authorizedInstanceIds, }: { + authorizedInstanceIds: readonly string[]; connectorId: string | null | undefined; connectorInstanceIdHint?: string | null; - grantStreamConnectionId?: string | null; ownerSubjectId: string | null | undefined; requestParams?: Record<string, unknown>; }) { const { connectionId: requestConnectionId, warnings: aliasWarnings } = resolveRequestConnectionId(requestParams); const { bindings, warnings } = await resolveFanInBindings({ + authorizedInstanceIds, connectorId, connectorInstanceIdHint, - grantStreamConnectionId, ownerSubjectId, requestConnectionId, }); diff --git a/reference-implementation/server/connector-run-evidence.ts b/reference-implementation/server/connector-run-evidence.ts index bdbdb680b..830ba16fd 100644 --- a/reference-implementation/server/connector-run-evidence.ts +++ b/reference-implementation/server/connector-run-evidence.ts @@ -13,10 +13,12 @@ import { listSpineCorrelations } from "../lib/spine.ts"; -export function getConnectorRunEvidenceSource( - source: { kind?: unknown; id?: unknown } | null | undefined -): string | null { - return source?.kind === "connector" && typeof source.id === "string" && source.id ? source.id : null; +export function getConnectorRunEvidenceConnectorId(storageBinding: unknown): string | null { + const connectorId = + storageBinding && typeof storageBinding === "object" && !Array.isArray(storageBinding) + ? (storageBinding as { connector_id?: unknown }).connector_id + : null; + return typeof connectorId === "string" && connectorId ? connectorId : null; } export async function getLatestConnectorRunSummary( diff --git a/reference-implementation/server/connector-schema-builder.ts b/reference-implementation/server/connector-schema-builder.ts index a044abfba..c5279b37f 100644 --- a/reference-implementation/server/connector-schema-builder.ts +++ b/reference-implementation/server/connector-schema-builder.ts @@ -14,7 +14,7 @@ import { listGrantedConnectionsForStream } from "./connection-identity.ts"; import { - getConnectorRunEvidenceSource, + getConnectorRunEvidenceConnectorId, getLatestConnectorRunSummary, getManifestRefreshPolicy, getMaximumStalenessSeconds, @@ -46,9 +46,9 @@ interface Manifest { } export interface ConnectorSchemaGrantStream extends Record<string, unknown> { - connection_id?: string; fields?: string[]; grantStreams?: Array<{ name: string }>; + instance_ids?: string[]; name: string; } @@ -101,13 +101,13 @@ function buildFreshness(lastUpdated = null) { } export async function getConnectorFreshnessEvidence({ - source, + storageBinding, manifest, }: { - source: ConnectorSource | null | undefined; + storageBinding: unknown; manifest: Manifest; }): Promise<ConnectorFreshnessEvidence> { - const connectorId = getConnectorRunEvidenceSource(source); + const connectorId = getConnectorRunEvidenceConnectorId(storageBinding); const refreshPolicy = getManifestRefreshPolicy(manifest); const [lastRun, lastSuccessfulRun] = await Promise.all([ getLatestConnectorRunSummary(connectorId), @@ -176,9 +176,9 @@ export async function buildConnectorSchemaItem({ manifest: Manifest; ownerSubjectId?: string | null; source: ConnectorSource | null | undefined; - storageBinding: unknown; + storageBinding: { connector_id?: string; [key: string]: unknown }; }) { - const connectorId = source?.kind === "connector" ? source.id : null; + const connectorId = storageBinding.connector_id ?? null; const streamSummaries: Array<{ last_updated?: string | null; name: string }> = grant ? await Reflect.apply(listStreams, undefined, [storageBinding, grant, manifest]) : await Reflect.apply(listAllStreams, undefined, [storageBinding]); @@ -195,39 +195,30 @@ export async function buildConnectorSchemaItem({ // Streams the loaded manifest declares — lets the expand-capabilities builder // distinguish "target stream not granted" from "target stream unknown". const manifestStreamNames = new Set(manifest.streams.map((stream) => stream.name)); - const freshnessEvidence = await getConnectorFreshnessEvidence({ manifest, source }); + const freshnessEvidence = await getConnectorFreshnessEvidence({ manifest, storageBinding }); - // Look up granted connections once per connector. For polyfill connectors - // we batch a single owner+connector store query and reuse the result for - // every stream entry, narrowing per-stream by `grant.streams[].connection_id` - // when the grant pins a single connection. For provider_native sources we - // omit the field — those grants do not address a connection_id. - let activeBindings: Array<{ connection_id: string }> | null = null; - if (connectorId && ownerSubjectId) { - activeBindings = await listGrantedConnectionsForStream({ - connectorId, - grantStreamConnectionId: null, - ownerSubjectId, - }); - } - - const streams = visibleStreams.map((manifestStream) => { - const lastUpdated = summaryByName.get(manifestStream.name)?.last_updated || null; - const streamGrant = grantStreamByName ? grantStreamByName.get(manifestStream.name) || null : null; - let grantedConnections: Array<{ connection_id: string }> | null = null; - if (activeBindings) { - const pin = streamGrant?.connection_id || null; - grantedConnections = pin ? activeBindings.filter((entry) => entry.connection_id === pin) : activeBindings; - } - return buildStreamMetadataEntry({ - freshness: buildConnectorAwareFreshness(freshnessEvidence, lastUpdated), - grantedConnections, - grantStreams, - manifestStream, - manifestStreamNames, - streamGrant, - }); - }); + const streams = await Promise.all( + visibleStreams.map(async (manifestStream) => { + const lastUpdated = summaryByName.get(manifestStream.name)?.last_updated || null; + const streamGrant = grantStreamByName ? grantStreamByName.get(manifestStream.name) || null : null; + let grantedConnections: Array<{ connection_id: string }> | null = null; + if (connectorId && ownerSubjectId) { + grantedConnections = await listGrantedConnectionsForStream({ + authorizedInstanceIds: grant ? streamGrant?.instance_ids || [] : null, + connectorId, + ownerSubjectId, + }); + } + return buildStreamMetadataEntry({ + freshness: buildConnectorAwareFreshness(freshnessEvidence, lastUpdated), + grantedConnections, + grantStreams, + manifestStream, + manifestStreamNames, + streamGrant, + }); + }) + ); const item: { connector_id?: string; @@ -251,18 +242,16 @@ export async function buildConnectorSchemaItem({ export async function getVisibleStreamFreshness({ tokenInfo, - source, storageBinding, stream, manifest, }: { manifest: Manifest; - source: ConnectorSource | null | undefined; storageBinding: unknown; stream: string; tokenInfo: { grant: Grant; pdpp_token_kind?: string } | null | undefined; }) { - const freshnessEvidence = await getConnectorFreshnessEvidence({ manifest, source }); + const freshnessEvidence = await getConnectorFreshnessEvidence({ manifest, storageBinding }); if (tokenInfo?.pdpp_token_kind === "owner") { const summaries: Array<{ last_updated?: string | null; name: string }> = await Reflect.apply( listAllStreams, diff --git a/reference-implementation/server/core-source-authorization.ts b/reference-implementation/server/core-source-authorization.ts new file mode 100644 index 000000000..086664908 --- /dev/null +++ b/reference-implementation/server/core-source-authorization.ts @@ -0,0 +1,563 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { createRequire } from "node:module"; +import { + type ResolvedGrant, + ResolvedGrantSchema, + type SelectionRequest, + SelectionRequestSchema, + type SourceDeclaration, + validateResolvedGrantSemantics, + validateSelectionRequestSemantics, +} from "@pdpp/reference-contract/public/source"; +import { passesGrantRecordConstraints } from "./record-filters.ts"; +import { requireSourceDeclaration, snapshotSourceDeclaration } from "./source-declaration.ts"; + +type JsonObject = Record<string, unknown>; + +export interface CoreSourceBinding { + id: string; + kind: "connector" | "provider_native"; +} + +export interface CoreStreamSelection { + fields?: string[] | undefined; + instance_ids?: string[] | undefined; + name: string; + resources?: string[] | undefined; + time_constraint?: { field: string; since?: string; until?: string } | undefined; + time_range?: { since?: string; until?: string } | undefined; + view?: string | undefined; +} + +export interface CoreSelection { + access_mode: string; + purpose_code: string; + purpose_description?: string | undefined; + retention?: unknown | undefined; + selection_preset?: string | undefined; + streams?: CoreStreamSelection[] | undefined; + type: string; +} + +export interface RetainedCoreConsentSnapshot { + declaration: SourceDeclaration; + declaration_version: string; + resolved_streams: CoreStreamSelection[]; + snapshot_version: "reference.source-declaration-snapshot.v1"; + source: CoreSourceBinding; + source_sensitivity: string; +} + +interface SchemaError { + instancePath?: string; + message?: string; +} + +interface SchemaValidator { + errors?: SchemaError[] | null; + (value: unknown): boolean; +} + +interface AjvInstance { + compile: (schema: object) => SchemaValidator; +} + +interface PrecollectedRecord { + data: JsonObject; + instance_id: string; + key: string; + stream: string; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (value && typeof value === "object") { + const object = value as Record<string, unknown>; + return `{${Object.keys(object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`) + .join(",")}}`; + } + const encoded = JSON.stringify(value); + if (encoded === undefined) { + fail("Retained consent evidence must contain only JSON values"); + } + return encoded; +} + +const requireFromContract = createRequire(import.meta.resolve("@pdpp/reference-contract")); +const Ajv2020 = requireFromContract("ajv/dist/2020.js") as new (options?: JsonObject) => AjvInstance; +const addFormats = requireFromContract("ajv-formats") as (ajv: AjvInstance) => void; +const ajv = new Ajv2020({ allErrors: true, strict: false }); +addFormats(ajv); +const validateResolvedGrantSchema = ajv.compile(ResolvedGrantSchema); +const validateSelectionRequestSchema = ajv.compile(SelectionRequestSchema); + +export class CoreSourceAuthorizationError extends Error { + readonly code = "source.authorization_details_invalid"; +} + +function fail(message: string): never { + throw new CoreSourceAuthorizationError(message); +} + +function cloneJson<T>(value: T): T { + return structuredClone(value); +} + +function isObject(value: unknown): value is JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isNonEmptyStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString); +} + +function hasExactKeys(value: unknown, expected: readonly string[]): boolean { + if (!isObject(value)) { + return false; + } + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + return actual.length === wanted.length && wanted.every((key, index) => actual[index] === key); +} + +function requireSourceBinding(value: unknown): CoreSourceBinding { + if (!hasExactKeys(value, ["id", "kind"])) { + fail("Source must include only kind and id"); + } + const source = value as JsonObject; + if (!isNonEmptyString(source.id) || (source.kind !== "connector" && source.kind !== "provider_native")) { + fail("Source kind and id are invalid"); + } + return { id: source.id, kind: source.kind }; +} + +function manifestStreams(declaration: SourceDeclaration): SourceDeclaration["streams"] { + return declaration.streams; +} + +function requiredFields(stream: SourceDeclaration["streams"][number]): string[] { + const required = stream.schema.required ?? []; + if (!(Array.isArray(required) && required.every(isNonEmptyString)) || new Set(required).size !== required.length) { + fail(`Stream '${stream.name}' schema.required must contain unique field names`); + } + return required; +} + +export function coreSchemaRequiredFields(stream: SourceDeclaration["streams"][number]): string[] { + return requiredFields(stream); +} + +function includeRequiredFields(fields: string[], stream: SourceDeclaration["streams"][number]): string[] { + return [...new Set([...fields, ...requiredFields(stream)])]; +} + +function resolveFields( + request: CoreStreamSelection, + stream: SourceDeclaration["streams"][number] +): Pick<CoreStreamSelection, "fields" | "view"> { + if (request.view && request.fields) { + fail(`Stream '${stream.name}' view and fields are mutually exclusive`); + } + if (request.view) { + const view = stream.views?.find((candidate) => candidate.id === request.view); + if (!(view && isNonEmptyStringArray(view.fields))) { + fail(`Unknown view '${request.view}' on stream '${stream.name}'`); + } + return { fields: includeRequiredFields(view.fields, stream), view: request.view }; + } + const properties = isObject(stream.schema.properties) ? stream.schema.properties : {}; + if (!request.fields) { + // JSONB does not preserve object-key insertion order. Field sets must + // resolve identically after a pending snapshot round-trips through either + // persistence backend. + const fields = Object.keys(properties).sort(); + if (fields.length === 0) { + fail(`Stream '${stream.name}' snapshot has no fields to authorize`); + } + return { fields }; + } + if (!(stream.selection.fields && isNonEmptyStringArray(request.fields))) { + fail(`Stream '${stream.name}' does not support the requested field selection`); + } + const unknown = request.fields.filter((field) => !(field in properties)); + if (unknown.length > 0) { + fail(`Unknown fields on stream '${stream.name}': ${unknown.join(", ")}`); + } + return { fields: includeRequiredFields(request.fields, stream) }; +} + +function resolveResources( + resources: string[] | undefined, + stream: SourceDeclaration["streams"][number] +): string[] | undefined { + if (resources === undefined) { + return; + } + if ( + !(stream.selection.resources && isNonEmptyStringArray(resources)) || + new Set(resources).size !== resources.length + ) { + fail(`Stream '${stream.name}' resources are not a supported unique non-empty selection`); + } + if (stream.primary_key.length === 1) { + return [...resources]; + } + for (const resource of resources) { + let components: unknown; + try { + components = JSON.parse(resource); + } catch { + fail(`Stream '${stream.name}' compound resource keys must be minified JSON string arrays`); + } + if ( + !Array.isArray(components) || + components.length !== stream.primary_key.length || + !components.every((component) => typeof component === "string") || + JSON.stringify(components) !== resource + ) { + fail(`Stream '${stream.name}' compound resource key has the wrong shape`); + } + } + return [...resources]; +} + +function resolveTimeConstraint( + timeRange: CoreStreamSelection["time_range"], + stream: SourceDeclaration["streams"][number] +): CoreStreamSelection["time_constraint"] { + if (timeRange === undefined) { + return; + } + if (!stream.consent_time_field) { + fail(`Stream '${stream.name}' does not support time_range`); + } + if (timeRange.since && timeRange.until && Date.parse(timeRange.since) > Date.parse(timeRange.until)) { + fail(`Stream '${stream.name}' time_range.since must not follow time_range.until`); + } + return { + field: stream.consent_time_field, + ...(timeRange.since ? { since: timeRange.since } : {}), + ...(timeRange.until ? { until: timeRange.until } : {}), + }; +} + +function projectResolvedStream(stream: CoreStreamSelection, requireInstances: boolean): CoreStreamSelection { + if (!isNonEmptyString(stream.name) || stream.name === "*" || !isNonEmptyStringArray(stream.fields)) { + fail("Resolved stream name and fields must be concrete and non-empty"); + } + const instanceIds = stream.instance_ids ?? []; + if ( + !Array.isArray(instanceIds) || + instanceIds.some((id) => !isNonEmptyString(id)) || + new Set(instanceIds).size !== instanceIds.length || + (requireInstances && instanceIds.length === 0) + ) { + fail(`Resolved stream '${stream.name}' has invalid instance_ids`); + } + return { + fields: [...stream.fields], + instance_ids: [...instanceIds], + name: stream.name, + ...(stream.resources ? { resources: cloneJson(stream.resources) } : {}), + ...(stream.time_constraint ? { time_constraint: cloneJson(stream.time_constraint) } : {}), + }; +} + +export function projectPendingCoreStream(stream: CoreStreamSelection): CoreStreamSelection { + return projectResolvedStream(stream, false); +} + +export function projectResolvedCoreGrantStream(stream: CoreStreamSelection): CoreStreamSelection { + return projectResolvedStream(stream, true); +} + +export function projectResolvedCoreGrantStreams(streams: CoreStreamSelection[]): CoreStreamSelection[] { + return streams.map(projectResolvedCoreGrantStream); +} + +export function resolveCoreSelection( + selection: Pick<CoreSelection, "selection_preset" | "streams">, + declarationInput: unknown +): CoreStreamSelection[] { + const declaration = requireSourceDeclaration(declarationInput); + let requests = selection.streams ?? []; + if (selection.selection_preset) { + const preset = declaration.selection_presets?.find((candidate) => candidate.id === selection.selection_preset); + if (!preset) { + fail(`Unknown selection_preset '${selection.selection_preset}'`); + } + requests = cloneJson(preset.streams) as CoreStreamSelection[]; + } + if (requests.length === 1 && requests[0]?.name === "*") { + const instanceIds = requests[0].instance_ids; + requests = manifestStreams(declaration).map((stream) => ({ + ...(instanceIds ? { instance_ids: [...instanceIds] } : {}), + name: stream.name, + })); + } + return requests.map((request) => { + const stream = declaration.streams.find((candidate) => candidate.name === request.name); + if (!stream) { + fail(`Unknown stream: ${request.name}`); + } + const fields = resolveFields(request, stream); + const instanceIds = request.instance_ids ?? []; + if (instanceIds.some((id) => !isNonEmptyString(id)) || new Set(instanceIds).size !== instanceIds.length) { + fail(`Stream '${stream.name}' instance_ids must be unique opaque handles`); + } + const resources = resolveResources(request.resources, stream); + const timeConstraint = resolveTimeConstraint(request.time_range, stream); + return projectResolvedStream( + { + ...fields, + instance_ids: [...instanceIds], + name: stream.name, + ...(resources ? { resources } : {}), + ...(timeConstraint ? { time_constraint: timeConstraint } : {}), + }, + false + ); + }); +} + +export function validateCoreSelectionRequest(value: unknown): SelectionRequest { + const candidate = cloneJson(value); + if (!validateSelectionRequestSchema(candidate)) { + const details = (validateSelectionRequestSchema.errors ?? []) + .map((error) => `${error.instancePath || "/"} ${error.message || "is invalid"}`) + .join("; "); + fail(`Selection request is invalid: ${details}`); + } + const request = candidate as SelectionRequest; + const semantic = validateSelectionRequestSemantics(request); + if (!semantic.ok) { + fail(`Selection request semantics are invalid: ${semantic.failures.map((item) => item.code).join(", ")}`); + } + return request; +} + +export function createRetainedCoreConsentSnapshot({ + declaration: declarationInput, + selection, + source, + sourceSensitivity, +}: { + declaration: unknown; + selection: CoreSelection; + source: unknown; + sourceSensitivity: string; +}): RetainedCoreConsentSnapshot { + const declaration = snapshotSourceDeclaration(declarationInput); + const retainedSource = requireSourceBinding(source); + if (declaration.source.id !== retainedSource.id || declaration.source.kind !== retainedSource.kind) { + fail("SourceDeclaration does not match the requested source"); + } + if (!isNonEmptyString(sourceSensitivity)) { + fail("Source sensitivity is required for consent display"); + } + return { + declaration, + declaration_version: declaration.declaration_version, + resolved_streams: resolveCoreSelection(selection, declaration), + snapshot_version: "reference.source-declaration-snapshot.v1", + source: retainedSource, + source_sensitivity: sourceSensitivity, + }; +} + +export function readRetainedCoreConsentSnapshot({ + selection, + snapshot: snapshotInput, + source, +}: { + selection: CoreSelection; + snapshot: unknown; + source: unknown; +}): RetainedCoreConsentSnapshot { + if (!isObject(snapshotInput)) { + fail("Pending consent declaration snapshot is missing"); + } + if ( + !hasExactKeys(snapshotInput, [ + "declaration", + "declaration_version", + "resolved_streams", + "snapshot_version", + "source", + "source_sensitivity", + ]) + ) { + fail("Pending consent declaration snapshot shape is unsupported"); + } + const snapshot = snapshotInput as unknown as RetainedCoreConsentSnapshot; + if (snapshot.snapshot_version !== "reference.source-declaration-snapshot.v1") { + fail("Pending consent declaration snapshot version is unsupported"); + } + if (!(Array.isArray(snapshot.resolved_streams) && isNonEmptyString(snapshot.source_sensitivity))) { + fail("Pending consent resolved declaration snapshot is incomplete"); + } + const declaration = requireSourceDeclaration(snapshot.declaration); + const retainedSource = requireSourceBinding(snapshot.source); + const requestSource = requireSourceBinding(source); + if ( + retainedSource.id !== requestSource.id || + retainedSource.kind !== requestSource.kind || + declaration.source.id !== retainedSource.id || + declaration.source.kind !== retainedSource.kind + ) { + fail("Pending consent declaration snapshot source does not match the request"); + } + if (snapshot.declaration_version !== declaration.declaration_version) { + fail("Pending consent declaration snapshot metadata does not match its bytes"); + } + const retainedStreams = snapshot.resolved_streams.map((stream) => projectResolvedStream(stream, false)); + const derivedStreams = resolveCoreSelection(selection, declaration); + if (canonicalJson(retainedStreams) !== canonicalJson(derivedStreams)) { + fail("Resolved streams are not derivable from the retained declaration and request"); + } + return cloneJson({ + ...snapshot, + declaration, + resolved_streams: retainedStreams, + source: retainedSource, + }); +} + +export function renderRetainedCoreConsent(args: Parameters<typeof readRetainedCoreConsentSnapshot>[0]) { + const snapshot = readRetainedCoreConsentSnapshot(args); + return { + display: cloneJson(snapshot.declaration.display), + resolvedStreams: cloneJson(snapshot.resolved_streams), + source: cloneJson(snapshot.source), + }; +} + +export function resolveCoreEligibleInstanceIds({ + eligibleInstanceIdsByStream, + streams, +}: { + eligibleInstanceIdsByStream: Readonly<Record<string, readonly string[]>>; + streams: CoreStreamSelection[]; +}): CoreStreamSelection[] { + return streams.map((stream) => { + const eligible = new Set(eligibleInstanceIdsByStream[stream.name] ?? []); + const requested = stream.instance_ids ?? []; + if (requested.length === 0) { + if (eligible.size !== 1) { + fail( + `Omitted instance_ids requires exactly one eligible instance for stream '${stream.name}', found ${eligible.size}` + ); + } + return projectResolvedStream({ ...stream, instance_ids: [...eligible] }, true); + } + const unauthorized = requested.filter((instanceId) => !eligible.has(instanceId)); + if (unauthorized.length > 0) { + fail(`Stream '${stream.name}' requested an ineligible instance handle`); + } + return projectResolvedStream(stream, true); + }); +} + +export function materializeCoreResolvedGrant({ + accessMode, + clientId, + expiresAt, + grantId, + issuedAt, + purposeCode, + purposeDescription, + resolvedStreams, + retention, + selectionPreset, + snapshot, + subjectId, +}: { + accessMode: string; + clientId: string; + expiresAt: string | null; + grantId: string; + issuedAt: string; + purposeCode: string; + purposeDescription?: string | undefined; + resolvedStreams: CoreStreamSelection[]; + retention?: unknown | undefined; + selectionPreset?: string | undefined; + snapshot: RetainedCoreConsentSnapshot; + subjectId: string; +}): ResolvedGrant { + const candidate = { + access_mode: accessMode, + client: { client_id: clientId }, + expires_at: expiresAt, + grant_id: grantId, + issued_at: issuedAt, + purpose_code: purposeCode, + ...(purposeDescription ? { purpose_description: purposeDescription } : {}), + ...(retention ? { retention } : {}), + ...(selectionPreset ? { selection_preset: selectionPreset } : {}), + source: cloneJson(snapshot.source), + source_declaration: { version: snapshot.declaration_version }, + streams: resolvedStreams.map((stream) => projectResolvedStream(stream, true)), + subject: { id: subjectId }, + version: "0.1.0" as const, + }; + return parseCoreResolvedGrant(candidate); +} + +/** Parse the closed Source resolved-grant contract used by every binding. */ +export function parseCoreResolvedGrant(value: unknown): ResolvedGrant { + const candidate = cloneJson(value); + if (!validateResolvedGrantSchema(candidate)) { + const details = (validateResolvedGrantSchema.errors ?? []) + .map((error) => `${error.instancePath || "/"} ${error.message || "is invalid"}`) + .join("; "); + fail(`Resolved grant is invalid: ${details}`); + } + const grant = candidate as ResolvedGrant; + const semantic = validateResolvedGrantSemantics(grant); + if (!semantic.ok) { + fail(`Resolved grant semantics are invalid: ${semantic.failures.map((item) => item.code).join(", ")}`); + } + return grant; +} + +export function servePrecollectedCoreRecords({ + grant, + instanceId, + records, + stream, +}: { + grant: ResolvedGrant; + instanceId: string; + records: PrecollectedRecord[]; + stream: string; +}): Array<{ data: JsonObject; key: string; stream: string }> { + const streamGrant = grant.streams.find((entry) => entry.name === stream); + if (!streamGrant?.instance_ids.includes(instanceId)) { + fail(`Stream '${stream}' is not authorized for instance '${instanceId}'`); + } + return records + .filter( + (record) => + record.stream === stream && + record.instance_id === instanceId && + passesGrantRecordConstraints(record.data, record.key, streamGrant, {}) + ) + .map((record) => ({ + data: Object.fromEntries( + streamGrant.fields.filter((field) => field in record.data).map((field) => [field, record.data[field]]) + ), + key: record.key, + stream: record.stream, + })); +} diff --git a/reference-implementation/server/credential-response-cache.ts b/reference-implementation/server/credential-response-cache.ts new file mode 100644 index 000000000..87b05c2bf --- /dev/null +++ b/reference-implementation/server/credential-response-cache.ts @@ -0,0 +1,21 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +export const CREDENTIAL_RESPONSE_CACHE_CONTROL = "no-store"; +export const CREDENTIAL_RESPONSE_PRAGMA = "no-cache"; + +export interface HeaderSetter { + setHeader: (name: string, value: string) => unknown; +} + +export function applyCredentialResponseNoStoreHeaders(res: HeaderSetter): void { + res.setHeader("Cache-Control", CREDENTIAL_RESPONSE_CACHE_CONTROL); + res.setHeader("Pragma", CREDENTIAL_RESPONSE_PRAGMA); +} + +export function credentialResponseNoStoreHeaders(): Record<string, string> { + return { + "Cache-Control": CREDENTIAL_RESPONSE_CACHE_CONTROL, + Pragma: CREDENTIAL_RESPONSE_PRAGMA, + }; +} diff --git a/reference-implementation/server/dataset-summary-read-model.ts b/reference-implementation/server/dataset-summary-read-model.ts index ba95fbba2..40966f839 100644 --- a/reference-implementation/server/dataset-summary-read-model.ts +++ b/reference-implementation/server/dataset-summary-read-model.ts @@ -156,7 +156,6 @@ const MAX_PERSISTED_TOP_CONNECTOR_CANDIDATES = 32; // the next pass and the projection metadata reports the deferral // honestly. const MAX_RECONCILE_BATCH = 256; -const SAFE_CONSENT_TIME_FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/; const EMPTY_SUMMARY = Object.freeze({ counts: { connector_count: 0, record_count: 0, stream_count: 0 }, ingested_time_bounds: { earliest: null, latest: null }, @@ -661,7 +660,7 @@ export async function reconcileDirtyDatasetSummaryRecordTimeBounds( await runSequentially(dirtyRows, async (row) => { throwIfAborted(signal); - if (!isSafeConsentTimeField(row.consent_time_field)) { + if (!hasDeclaredConsentTimeField(row.consent_time_field)) { deferred += 1; return; } @@ -1074,8 +1073,8 @@ function hasDirtyRecordTimeBounds() { return Number(row?.count || 0) > 0; } -function isSafeConsentTimeField(field: unknown): field is string { - return typeof field === "string" && SAFE_CONSENT_TIME_FIELD.test(field); +function hasDeclaredConsentTimeField(field: unknown): field is string { + return typeof field === "string" && field.length > 0; } function markDatasetSummaryProjectionRebuilding(at: IsoTimestamp): number { diff --git a/reference-implementation/server/db.ts b/reference-implementation/server/db.ts index 4d34ed97a..5bc385893 100644 --- a/reference-implementation/server/db.ts +++ b/reference-implementation/server/db.ts @@ -719,6 +719,7 @@ CREATE TABLE IF NOT EXISTS tokens ( token_id TEXT PRIMARY KEY, grant_id TEXT, package_id TEXT, + refresh_family_id TEXT, subject_id TEXT NOT NULL, client_id TEXT, token_kind TEXT NOT NULL, @@ -727,6 +728,19 @@ CREATE TABLE IF NOT EXISTS tokens ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS consent_exchange_codes ( + code_hash TEXT PRIMARY KEY, + proof_hash TEXT, + token_id TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + redeemed_at TEXT, + FOREIGN KEY(token_id) REFERENCES tokens(token_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_consent_exchange_codes_expiry + ON consent_exchange_codes(expires_at); + CREATE TABLE IF NOT EXISTS pending_consents ( device_code TEXT PRIMARY KEY, user_code TEXT NOT NULL UNIQUE, @@ -745,6 +759,9 @@ CREATE TABLE IF NOT EXISTS pending_consents ( denied_at TEXT, interval_seconds INTEGER NOT NULL DEFAULT 2, last_polled_at TEXT, + approval_review_revision TEXT, + approval_review_digest TEXT, + approval_review_json TEXT, -- approval_id is a non-redeemable opaque public id projected to operator -- read surfaces (/_ref/approvals) so callers cannot lift the live -- device_code (which is bearer-equivalent in the consent flow when @@ -757,6 +774,30 @@ CREATE TABLE IF NOT EXISTS pending_consents ( CREATE INDEX IF NOT EXISTS idx_pending_consents_status_expires ON pending_consents(status, expires_at); +CREATE TABLE IF NOT EXISTS agent_connect_attempts ( + id TEXT PRIMARY KEY, + request_uri TEXT NOT NULL, + client_id TEXT, + polling_code_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + approval_url TEXT NOT NULL, + token_url TEXT NOT NULL, + interval_seconds INTEGER NOT NULL DEFAULT 2, + created_at TEXT NOT NULL, + expires_at_ms INTEGER NOT NULL, + completed_at TEXT, + grant_id TEXT, + grant_json TEXT, + token TEXT, + response_json TEXT +); + +CREATE INDEX IF NOT EXISTS idx_agent_connect_attempts_request_uri + ON agent_connect_attempts(request_uri, status); + +CREATE INDEX IF NOT EXISTS idx_agent_connect_attempts_status_expires + ON agent_connect_attempts(status, expires_at_ms); + CREATE TABLE IF NOT EXISTS owner_device_auth ( device_code TEXT PRIMARY KEY, user_code TEXT NOT NULL UNIQUE, @@ -1351,6 +1392,9 @@ CREATE INDEX IF NOT EXISTS idx_oauth_authorization_codes_client_status CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( refresh_token_hash TEXT PRIMARY KEY, + family_id TEXT NOT NULL, + generation INTEGER NOT NULL, + parent_generation INTEGER, client_id TEXT NOT NULL, grant_id TEXT, package_id TEXT, @@ -1359,6 +1403,7 @@ CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( created_at TEXT NOT NULL, expires_at TEXT, last_used_at TEXT, + superseded_at TEXT, revoked_at TEXT ); @@ -1366,7 +1411,6 @@ CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_grant ON oauth_refresh_tokens(grant_id, status); CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_client_status ON oauth_refresh_tokens(client_id, status, expires_at); - CREATE TABLE IF NOT EXISTS grant_packages ( package_id TEXT PRIMARY KEY, subject_id TEXT NOT NULL, @@ -5470,11 +5514,67 @@ export function initDb(path = ":memory:", opts: InitDbOptions = {}): DatabaseHan // Adds the non-redeemable `approval_id` column on the consent + device // auth tables; see SCHEMA comment for rationale. runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "pending_consents", "approval_id", "TEXT")); + runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "pending_consents", "approval_review_revision", "TEXT")); + runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "pending_consents", "approval_review_digest", "TEXT")); + runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "pending_consents", "approval_review_json", "TEXT")); runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "pending_consents", "interval_seconds", "INTEGER NOT NULL DEFAULT 2") ); runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "pending_consents", "last_polled_at", "TEXT")); runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "owner_device_auth", "approval_id", "TEXT")); + runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "consent_exchange_codes", "proof_hash", "TEXT")); + // Add the v0.1 refresh-family columns without reconstructing legacy token + // state. The fail-closed migration below revokes families and bound bearers + // that lack the new linkage, so they require fresh authorization. + runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "oauth_refresh_tokens", "family_id", "TEXT")); + runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "oauth_refresh_tokens", "generation", "INTEGER")); + runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "oauth_refresh_tokens", "parent_generation", "INTEGER")); + runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "oauth_refresh_tokens", "superseded_at", "TEXT")); + runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "tokens", "refresh_family_id", "TEXT")); + runWithSqliteBusyRetrySync(() => { + raw.exec( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_family_generation ON oauth_refresh_tokens(family_id, generation)" + ); + }); + runWithSqliteBusyRetrySync(() => { + raw.exec("CREATE INDEX IF NOT EXISTS idx_tokens_refresh_family ON tokens(refresh_family_id, revoked)"); + }); + runWithSqliteBusyRetrySync(() => { + raw.transaction(() => + raw.exec(` + UPDATE tokens + SET revoked = 1 + WHERE revoked = 0 + AND ( + grant_id IN ( + SELECT legacy.grant_id + FROM oauth_refresh_tokens legacy + WHERE legacy.grant_id IS NOT NULL + AND legacy.status <> 'revoked' + AND NOT EXISTS ( + SELECT 1 FROM tokens linked WHERE linked.refresh_family_id = legacy.family_id + ) + ) + OR package_id IN ( + SELECT legacy.package_id + FROM oauth_refresh_tokens legacy + WHERE legacy.package_id IS NOT NULL + AND legacy.status <> 'revoked' + AND NOT EXISTS ( + SELECT 1 FROM tokens linked WHERE linked.refresh_family_id = legacy.family_id + ) + ) + ); + UPDATE oauth_refresh_tokens + SET status = 'revoked', + revoked_at = COALESCE(revoked_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + WHERE status <> 'revoked' + AND NOT EXISTS ( + SELECT 1 FROM tokens linked WHERE linked.refresh_family_id = oauth_refresh_tokens.family_id + ); + `) + )(); + }); runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "device_exporters", "agent_version", "TEXT")); runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "device_exporters", "collector_protocol_version", "TEXT")); runWithSqliteBusyRetrySync(() => addColumnIfMissing(raw, "device_exporters", "last_heartbeat_at", "TEXT")); diff --git a/reference-implementation/server/grant-package-lifecycle.ts b/reference-implementation/server/grant-package-lifecycle.ts deleted file mode 100644 index c0dda69a9..000000000 --- a/reference-implementation/server/grant-package-lifecycle.ts +++ /dev/null @@ -1,1685 +0,0 @@ -// Copyright The PDP-Connect Contributors -// SPDX-License-Identifier: Apache-2.0 - -/** - * Hosted MCP grant-package lifecycle — package/group/member row operations, - * package-token issuance, owner/MCP package views, and package cascade - * orchestration. - * - * DOES NOT own: generic grant issuance (external dep), generic grant - * revocation (external dep), OAuth authorization-code or refresh-token - * exchange (follow-up / OAuth-owned), request-boundary parsing (stays in - * auth.js), or the staged-consent approval wrapper (consent/auth flow). - * - * Invariants: - * - Package token never replaces or weakens child-grant enforcement. - * - connection_id is identity metadata, not an authority boundary. - * - parent_package_id is lineage metadata only; does not grant prior access. - * - MCP access returns only ACTIVE packages with active non-revoked/non-expired members. - * - Owner views CAN show revoked/history where MCP access must hide them. - * - Revocation cascades: package token + members + child grants + refresh tokens. - * - Partial-failure reporting is preserved. - * - Postgres/SQLite store parity. - */ - -import { randomBytes } from "node:crypto"; -import { allowUnboundedReadAcknowledged, exec, getOne, referenceQueries } from "../lib/db.ts"; -import { createTraceContext, emitSpineEvent, type SpineEventInput, type SpineTraceContext } from "../lib/spine.ts"; -import { listActiveBindingsForGrant, projectBindingForWire } from "./connection-identity.ts"; -import { isPostgresStorageBackend, postgresQuery } from "./postgres-storage.ts"; - -// --------------------------------------------------------------------------- -// Local pure utilities (no auth.js dep needed for these) -// --------------------------------------------------------------------------- - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function generateId(prefix = "id"): string { - return `${prefix}_${randomBytes(8).toString("hex")}`; -} - -function nowIso(): string { - return new Date().toISOString(); -} - -function parsePackageJson(raw: unknown): Record<string, unknown> | null { - if (typeof raw !== "string" || !raw.trim()) { - return null; - } - try { - return JSON.parse(raw) as Record<string, unknown>; - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Postgres dialect shims (mirror the wrappers defined in auth.js) -// --------------------------------------------------------------------------- - -async function pgOne(sql: string, params: unknown[] = []): Promise<Record<string, unknown> | null> { - const result = await postgresQuery(sql, params); - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - return (result.rows[0] as Record<string, unknown>) ?? null; -} - -async function pgExec(sql: string, params: unknown[] = []): Promise<{ changes: number }> { - const result = await postgresQuery(sql, params); - return { changes: (result.rowCount as number | null) ?? 0 }; -} - -function requireReferenceQuery<K extends keyof typeof referenceQueries>(name: K): (typeof referenceQueries)[K] { - const query = referenceQueries[name]; - if (!query || typeof query.sql !== "string" || query.sql.length === 0) { - throw new Error(`Missing SQLite reference query: ${String(name)}`); - } - return query; -} - -// --------------------------------------------------------------------------- -// Internal row types -// --------------------------------------------------------------------------- - -interface MemberRow extends Record<string, unknown> { - readonly added_at: string; - readonly grant_id: string; - readonly grant_json?: string | null; - readonly grant_status?: string; - readonly member_revoked_at?: string | null; - readonly member_status?: string; - readonly package_id: string; - readonly revoked_at: string | null; - readonly source_json: string | null; - readonly status: string; - readonly storage_binding_json?: string | null; - readonly token_expires_at?: string | null; - readonly token_id: string; - readonly token_revoked?: boolean | number | null; -} - -// --------------------------------------------------------------------------- -// Public output types (consumed by routes and index.js) -// --------------------------------------------------------------------------- - -/** Normalized grant_packages row returned by owner/MCP views. */ -export interface NormalizedPackage { - readonly approved_at: string | null; - readonly client_id: string; - readonly created_at: string; - readonly package: Record<string, unknown> | null; - readonly package_id: string; - readonly parent_package_id: string | null; - readonly revoked_at: string | null; - readonly scenario_id: string | null; - readonly status: string; - readonly subject_id: string; - readonly trace_id: string | null; -} - -/** One active member in a package access result (MCP fan-out path). */ -export interface PackageAccessMember { - readonly connection_id: string | null; - readonly grant: Record<string, unknown>; - readonly grant_id: string; - readonly grant_storage_binding: { readonly connector_id: string } | null; - readonly package_id: string; - readonly source: Record<string, unknown> | null; - readonly token: string; -} - -/** Result of getGrantPackageAccess (MCP fan-out). */ -export interface GrantPackageAccess { - readonly members: readonly PackageAccessMember[]; - readonly package: NormalizedPackage; -} - -/** One child entry in an owner detail view. */ -export interface PackageChildEntry { - readonly added_at: string; - readonly grant_id: string; - readonly grant_status: string; - readonly member_status: string; - readonly revoked_at: string | null; - readonly source: Record<string, unknown> | null; -} - -/** Owner detail view of a grant package (includes all members). */ -export interface GrantPackageSummaryRow extends NormalizedPackage { - readonly children: readonly PackageChildEntry[]; - readonly member_count: number; -} - -/** One entry in the owner list page. */ -export interface GrantPackageListEntry extends NormalizedPackage { - readonly member_count: number; -} - -/** Paginated owner list result. */ -export interface GrantPackageListPage { - readonly data: readonly GrantPackageListEntry[]; - readonly has_more: boolean; - readonly limit: number; - readonly next_cursor: string | null; -} - -/** Cumulative per-client access across a linked package lineage. */ -export interface CumulativeClientAccess { - readonly active_child_count: number; - readonly children: readonly (PackageChildEntry & { readonly package_id: string })[]; - readonly client_id: string; - readonly package_count: number; - readonly packages: readonly { - readonly package_id: string; - readonly parent_package_id: string | null; - readonly status: string; - readonly created_at: string; - readonly approved_at: string | null; - readonly revoked_at: string | null; - readonly member_count: number; - }[]; - readonly root_package_id: string; - readonly subject_id: string; -} - -/** Result of revokeGrantPackage. */ -export interface GrantPackageRevokeResult { - readonly not_revoked_child_grants: readonly { - readonly grant_id: string; - readonly error: { readonly code: string; readonly message: string }; - }[]; - readonly package_id: string; - readonly revoked_at: string | null; - readonly revoked_child_grants: readonly string[]; - readonly status: "revoked" | "partial_failure"; -} - -/** Return shape of createHostedMcpGrantPackage. */ -export interface PackageGrantResult { - readonly child_grants: readonly { - readonly grant: Record<string, unknown>; - readonly token: string; - readonly source: Record<string, unknown> | null; - readonly connection_id: string | null; - }[]; - readonly package: Record<string, unknown>; - readonly package_id: string; - readonly token: string; - readonly trace_context: SpineTraceContext; -} - -/** Return shape of requireValidParentPackageLinkage. */ -export interface ValidParentPackage extends NormalizedPackage {} - -// --------------------------------------------------------------------------- -// External dependency types (injected from auth.js — no import-back) -// --------------------------------------------------------------------------- - -interface SourceBinding { - readonly id: string; - readonly kind: "connector" | "provider_native"; -} - -interface StorageBinding { - readonly connector_id: string; -} - -interface RegisteredClient { - readonly client_id: string; - readonly metadata: Record<string, unknown>; - readonly registration_mode: string; -} - -interface PendingRequest { - authorization_details?: unknown[]; - client?: Record<string, unknown>; - client_id?: string; - manifest_version?: string; - selection?: Record<string, unknown>; - source_binding?: { kind: string; id: string } | null; - storage_binding?: StorageBinding | null; - trace_context?: SpineTraceContext; - [key: string]: unknown; -} - -interface GrantManifest { - readonly version: string; - [key: string]: unknown; -} - -interface PersistedGrantState { - readonly grant: Record<string, unknown>; - readonly sourceBinding: SourceBinding; - readonly storageBinding: StorageBinding | null; -} - -interface PersistGrantArgs { - readonly accessMode: string; - readonly clientId: string; - readonly expiresAt: string | null; - readonly grantId: string; - readonly grantJson: string; - readonly issuedAt: string; - readonly scenarioId: string | null; - readonly storageBindingJson: string | null; - readonly subjectId: string; - readonly traceId: string | null; -} - -/** - * External dependencies that auth.js injects. The module never imports auth.js; - * auth.js calls createGrantPackageLifecycle once and re-exports the bound methods. - */ -export interface GrantPackageLifecycleDeps { - /** Build the client display object from registration metadata. */ - readonly buildClientDisplayFromRegistration: (metadata: Record<string, unknown>) => Record<string, unknown> | null; - /** Build an OAuth error with a code property. */ - readonly buildOAuthAuthorizationCodeError: (code: string, message: string) => Error & { code: string }; - /** Extract the source binding descriptor from a grant object. */ - readonly describeGrantSource: (grant: Record<string, unknown>) => { kind: string; id: string } | null; - /** Describe a source binding as a {kind, id} object, or null. */ - readonly describeSourceBinding: (sourceBinding: unknown) => { kind: string; id: string } | null; - /** Generic child-grant issuer (stays in auth.js — used by non-package paths too). */ - readonly issueToken: ( - grantId: string, - subjectId: string, - clientId: string, - expiresAt: string | null, - meta?: Record<string, unknown> - ) => Promise<string>; - /** Normalize a raw grant-init payload into a structured pending request. */ - readonly normalizePendingGrantRequest: ( - input: Record<string, unknown>, - opts?: Record<string, unknown> - ) => PendingRequest; - /** Normalize a raw storage binding, returning null if invalid. */ - readonly normalizeStorageBinding: (storageBinding: unknown) => StorageBinding | null; - /** Generic grants-row persistence; owned by auth.js/generic grant issuance. */ - readonly persistGrant: (args: PersistGrantArgs) => Promise<unknown>; - /** Resolve the connector manifest for a binding pair. */ - readonly requireGrantManifestForBindings: ( - sourceBinding: SourceBinding, - storageBinding: StorageBinding | null, - opts?: Record<string, unknown> - ) => Promise<GrantManifest>; - /** Validate and resolve client registration against the pending request. */ - readonly requirePendingRequestClientRegistration: ( - request: PendingRequest, - opts?: Record<string, unknown> - ) => Promise<RegisteredClient>; - /** Parse persisted grant JSON + storage-binding JSON into structured state. */ - readonly requirePersistedGrantState: (row: Record<string, unknown>) => PersistedGrantState; - /** Validate and return structured source + storage bindings. */ - readonly requireStructuredPendingRequestBindings: (request: PendingRequest) => { - sourceBinding: SourceBinding; - storageBinding: StorageBinding | null; - }; - /** Validate the pending request structure (throws on failure). */ - readonly requireStructuredPendingRequestShape: (request: PendingRequest) => void; - /** Resolve selected streams from the manifest. */ - readonly resolveGrantSelection: ( - selection: Record<string, unknown>, - manifest: GrantManifest - ) => readonly Record<string, unknown>[]; - /** Resolve a registered OAuth client. */ - readonly resolveOAuthClient: (clientId: string, opts?: Record<string, unknown>) => Promise<RegisteredClient | null>; - /** Generic grant revoker (stays in auth.js). */ - readonly revokeGrant: (grantId: string, context?: Record<string, unknown>) => Promise<void>; - /** Serialize a storage binding to JSON string or null. */ - readonly serializeStorageBinding: (storageBinding: StorageBinding | null) => string | null; -} - -/** The bound lifecycle methods returned from createGrantPackageLifecycle. */ -export interface GrantPackageLifecycle { - readonly createHostedMcpGrantPackage: (args: { - clientId: string; - authorizationDetails: unknown[]; - storageBindings?: Array<{ connector_id: string }>; - connectionIds?: Array<string | null>; - sourceMetadata?: Record<string, unknown>[]; - subjectId?: string; - opts?: Record<string, unknown>; - }) => Promise<PackageGrantResult>; - readonly getCumulativeClientAccessForPackage: (packageId: string) => Promise<CumulativeClientAccess | null>; - readonly getGrantPackageAccess: (packageId: string) => Promise<GrantPackageAccess | null>; - readonly getGrantPackageForOwner: (packageId: string) => Promise<GrantPackageSummaryRow | null>; - readonly getGrantPackageIdForGrant: (grantId: string) => Promise<string | null>; - /** - * Issue a package-scoped access token and record a token.issued spine event. - * Kept on the lifecycle so auth.js (OAuth refresh path) doesn't own a copy. - */ - readonly issuePackageToken: ( - packageId: string, - subjectId: string, - clientId: string, - expiresAt: string | null, - meta?: Record<string, unknown> - ) => Promise<string>; - readonly listActivePackageIdsForClient: (clientId: string) => Promise<readonly string[]>; - readonly listGrantPackagesForOwner: (opts?: { - limit?: number; - cursor?: string | null; - }) => Promise<GrantPackageListPage>; - /** - * Persist the rows for a staged-batch-consent package (package + child grants - * + members + package token). Called by the staged-approval path in auth.js - * after it has resolved bindings, narrowed streams, and built the package - * envelope — the module owns the DB writes and token issuance so the helpers - * don't live in two places. - */ - readonly persistStagedBatchPackage: (args: { - packageId: string; - subjectId: string; - registeredClient: { client_id: string; registration_mode: string }; - packageEnvelope: Record<string, unknown>; - parentPackageId: string | null; - traceContext: SpineTraceContext; - createdAt: string; - resolvedEntries: ReadonlyArray<{ - slice: Record<string, unknown>; - sourceBinding: { kind: string; id: string }; - storageBinding: { connector_id: string } | null; - manifest: { version: string }; - resolvedStreams: readonly Record<string, unknown>[]; - }>; - }) => Promise<{ - childGrants: Array<{ - grant: Record<string, unknown>; - token: string; - source: Record<string, unknown> | null; - }>; - packageToken: string; - }>; - /** - * Validate a parent_package_id before linking a new package to it. - * Used by the staged-consent approval path in auth.js (stays there; calls - * this from the lifecycle so auth.js stops owning the lineage invariant). - */ - readonly requireValidParentPackageLinkage: ( - parentPackageId: string | null | undefined, - opts?: { clientId?: string; subjectId?: string } - ) => Promise<NormalizedPackage | null>; - readonly revokeGrantPackage: ( - packageId: string, - context?: Record<string, unknown> - ) => Promise<GrantPackageRevokeResult>; -} - -// --------------------------------------------------------------------------- -// Internal grant-package row store (dialect-isolated, not exported) -// --------------------------------------------------------------------------- - -interface GrantPackageStore { - getPackageById: (packageId: string) => Promise<Record<string, unknown> | null> | Record<string, unknown> | null; - getPackageIdForGrant: (grantId: string) => Promise<Record<string, unknown> | null> | Record<string, unknown> | null; - insertPackage: (args: { - packageId: string; - subjectId: string; - clientId: string; - packageJson: string; - parentPackageId: string | null; - traceId: string; - scenarioId: string; - createdAt: string; - approvedAt: string; - }) => Promise<{ changes: number }> | { changes: number }; - insertPackageMember: (args: { - packageId: string; - grantId: string; - tokenId: string; - sourceJson: string; - addedAt: string; - }) => Promise<{ changes: number }> | { changes: number }; - insertPackageToken: (args: { - tokenId: string; - packageId: string; - subjectId: string; - clientId: string; - expiresAt: string | null; - }) => Promise<{ changes: number }> | { changes: number }; - listActiveMembers: (packageId: string) => Promise<readonly MemberRow[]> | readonly MemberRow[]; - listAllMembers: (packageId: string) => Promise<readonly MemberRow[]> | readonly MemberRow[]; - markMemberRevoked: (args: { - packageId: string; - grantId: string; - revokedAt: string; - }) => Promise<{ changes: number }> | { changes: number }; - markPackageRevokedCascade: (args: { packageId: string; revokedAt: string }) => Promise<void> | void; -} - -const postgresGrantPackageStore: GrantPackageStore = { - getPackageById: (packageId: string): Promise<Record<string, unknown> | null> => - pgOne( - `SELECT package_id, subject_id, client_id, status, package_json::text AS package_json, - parent_package_id, trace_id, scenario_id, created_at, approved_at, revoked_at - FROM grant_packages - WHERE package_id = $1`, - [packageId] - ), - - getPackageIdForGrant: (grantId: string): Promise<Record<string, unknown> | null> => - pgOne( - `SELECT package_id - FROM grant_package_members - WHERE grant_id = $1 - ORDER BY added_at - LIMIT 1`, - [grantId] - ), - - insertPackage: ({ - packageId, - subjectId, - clientId, - packageJson, - parentPackageId, - traceId, - scenarioId, - createdAt, - approvedAt, - }: { - packageId: string; - subjectId: string; - clientId: string; - packageJson: string; - parentPackageId: string | null; - traceId: string; - scenarioId: string; - createdAt: string; - approvedAt: string; - }): Promise<{ changes: number }> => - pgExec( - `INSERT INTO grant_packages( - package_id, subject_id, client_id, status, package_json, - parent_package_id, trace_id, scenario_id, created_at, approved_at, revoked_at - ) VALUES($1, $2, $3, 'active', $4::jsonb, $5, $6, $7, $8, $9, NULL)`, - [packageId, subjectId, clientId, packageJson, parentPackageId, traceId, scenarioId, createdAt, approvedAt] - ), - - insertPackageMember: ({ - packageId, - grantId, - tokenId, - sourceJson, - addedAt, - }: { - packageId: string; - grantId: string; - tokenId: string; - sourceJson: string; - addedAt: string; - }): Promise<{ changes: number }> => - pgExec( - `INSERT INTO grant_package_members( - package_id, grant_id, token_id, source_json, status, added_at, revoked_at - ) VALUES($1, $2, $3, $4::jsonb, 'active', $5, NULL)`, - [packageId, grantId, tokenId, sourceJson, addedAt] - ), - insertPackageToken: ({ - tokenId, - packageId, - subjectId, - clientId, - expiresAt, - }: { - tokenId: string; - packageId: string; - subjectId: string; - clientId: string; - expiresAt: string | null; - }): Promise<{ changes: number }> => - pgExec( - `INSERT INTO tokens(token_id, grant_id, package_id, subject_id, client_id, token_kind, expires_at) - VALUES($1, NULL, $2, $3, $4, 'mcp_package', $5)`, - [tokenId, packageId, subjectId, clientId, expiresAt] - ), - - listActiveMembers: async (packageId: string): Promise<MemberRow[]> => - ( - await postgresQuery( - `SELECT gm.package_id, gm.grant_id, gm.token_id, gm.source_json::text AS source_json, - gm.status, gm.added_at, gm.revoked_at, - g.status AS grant_status, g.grant_json::text AS grant_json, - g.storage_binding_json::text AS storage_binding_json, - t.revoked AS token_revoked, t.expires_at AS token_expires_at - FROM grant_package_members gm - JOIN grants g ON gm.grant_id = g.grant_id - JOIN tokens t ON gm.token_id = t.token_id - WHERE gm.package_id = $1 - AND gm.status = 'active' - ORDER BY gm.added_at, gm.grant_id`, - [packageId] - ) - ).rows as MemberRow[], - - listAllMembers: async (packageId: string): Promise<MemberRow[]> => - ( - await postgresQuery( - `SELECT gm.package_id, gm.grant_id, gm.source_json::text AS source_json, - gm.status AS member_status, gm.added_at, gm.revoked_at AS member_revoked_at, - g.status AS grant_status - FROM grant_package_members gm - JOIN grants g ON gm.grant_id = g.grant_id - WHERE gm.package_id = $1 - ORDER BY gm.added_at, gm.grant_id`, - [packageId] - ) - ).rows as MemberRow[], - - markMemberRevoked: ({ - packageId, - grantId, - revokedAt, - }: { - packageId: string; - grantId: string; - revokedAt: string; - }): Promise<{ changes: number }> => - pgExec( - `UPDATE grant_package_members - SET status = 'revoked', revoked_at = $1 - WHERE package_id = $2 AND grant_id = $3 AND status = 'active'`, - [revokedAt, packageId, grantId] - ), - - markPackageRevokedCascade: async ({ - packageId, - revokedAt, - }: { - packageId: string; - revokedAt: string; - }): Promise<void> => { - await pgExec( - "UPDATE grant_packages SET status = 'revoked', revoked_at = $1 WHERE package_id = $2 AND status = 'active'", - [revokedAt, packageId] - ); - await pgExec("UPDATE tokens SET revoked = TRUE WHERE package_id = $1", [packageId]); - await pgExec( - "UPDATE grant_package_members SET status = 'revoked', revoked_at = $1 WHERE package_id = $2 AND status = 'active'", - [revokedAt, packageId] - ); - await pgExec( - "UPDATE oauth_refresh_tokens SET status = 'revoked', revoked_at = $1 WHERE package_id = $2 AND status = 'active'", - [revokedAt, packageId] - ); - }, -}; - -const sqliteGrantPackageStore: GrantPackageStore = { - getPackageById: (packageId: string) => - getOne<Record<string, unknown>>(requireReferenceQuery("authGrantPackagesGetById"), [packageId]), - - getPackageIdForGrant: (grantId: string) => - getOne<Record<string, unknown>>(requireReferenceQuery("authGrantPackageMembersGetPackageIdByGrant"), [grantId]), - - insertPackage: ({ - packageId, - subjectId, - clientId, - packageJson, - parentPackageId, - traceId, - scenarioId, - createdAt, - approvedAt, - }: { - packageId: string; - subjectId: string; - clientId: string; - packageJson: string; - parentPackageId: string | null; - traceId: string; - scenarioId: string; - createdAt: string; - approvedAt: string; - }) => - exec(requireReferenceQuery("authGrantPackagesInsert"), [ - packageId, - subjectId, - clientId, - packageJson, - parentPackageId, - traceId, - scenarioId, - createdAt, - approvedAt, - ]), - - insertPackageMember: ({ - packageId, - grantId, - tokenId, - sourceJson, - addedAt, - }: { - packageId: string; - grantId: string; - tokenId: string; - sourceJson: string; - addedAt: string; - }) => - exec(requireReferenceQuery("authGrantPackageMembersInsert"), [packageId, grantId, tokenId, sourceJson, addedAt]), - insertPackageToken: ({ - tokenId, - packageId, - subjectId, - clientId, - expiresAt, - }: { - tokenId: string; - packageId: string; - subjectId: string; - clientId: string; - expiresAt: string | null; - }) => exec(requireReferenceQuery("authTokensInsertMcpPackage"), [tokenId, packageId, subjectId, clientId, expiresAt]), - - listActiveMembers: (packageId: string): readonly MemberRow[] => - allowUnboundedReadAcknowledged<MemberRow>(requireReferenceQuery("authGrantPackageMembersListActiveByPackage"), [ - packageId, - ]), - - listAllMembers: (packageId: string): readonly MemberRow[] => - allowUnboundedReadAcknowledged<MemberRow>(requireReferenceQuery("authGrantPackageMembersListAllByPackage"), [ - packageId, - ]), - - markMemberRevoked: ({ packageId, grantId, revokedAt }: { packageId: string; grantId: string; revokedAt: string }) => - exec(requireReferenceQuery("authGrantPackageMembersMarkRevokedByGrant"), [revokedAt, packageId, grantId]), - - markPackageRevokedCascade: ({ packageId, revokedAt }: { packageId: string; revokedAt: string }): void => { - exec(requireReferenceQuery("authGrantPackagesMarkRevoked"), [revokedAt, packageId]); - exec(requireReferenceQuery("authTokensRevokeByPackage"), [packageId]); - exec(requireReferenceQuery("authGrantPackageMembersMarkRevokedByPackage"), [revokedAt, packageId]); - exec(requireReferenceQuery("authOauthRefreshTokensRevokeByPackage"), [revokedAt, packageId]); - }, -}; - -function getGrantPackageStore(): GrantPackageStore { - return isPostgresStorageBackend() ? postgresGrantPackageStore : sqliteGrantPackageStore; -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -function normalizePackageRow(row: Record<string, unknown> | null): NormalizedPackage | null { - if (!row) { - return null; - } - return { - approved_at: (row.approved_at as string | null) ?? null, - client_id: row.client_id as string, - created_at: row.created_at as string, - package: parsePackageJson(row.package_json), - package_id: row.package_id as string, - parent_package_id: (row.parent_package_id as string | null) ?? null, - revoked_at: (row.revoked_at as string | null) ?? null, - scenario_id: (row.scenario_id as string | null) ?? null, - status: row.status as string, - subject_id: row.subject_id as string, - trace_id: (row.trace_id as string | null) ?? null, - }; -} - -async function getGrantPackageRow(packageId: string): Promise<NormalizedPackage | null> { - if (!isNonEmptyString(packageId)) { - return null; - } - const row = await getGrantPackageStore().getPackageById(packageId); - return normalizePackageRow(row as Record<string, unknown> | null); -} - -function encodeGrantPackageCursor(row: { created_at: string; package_id: string }): string { - return Buffer.from(JSON.stringify({ created_at: row.created_at, package_id: row.package_id }), "utf8").toString( - "base64url" - ); -} - -function decodeGrantPackageCursor( - cursor: string | null | undefined -): { created_at: string; package_id: string } | null { - if (!isNonEmptyString(cursor)) { - return null; - } - try { - const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown; - if ( - decoded !== null && - typeof decoded === "object" && - isNonEmptyString((decoded as Record<string, unknown>).created_at) && - isNonEmptyString((decoded as Record<string, unknown>).package_id) - ) { - return { - created_at: (decoded as Record<string, unknown>).created_at as string, - package_id: (decoded as Record<string, unknown>).package_id as string, - }; - } - } catch { - // fall through - } - const err = Object.assign(new Error("Invalid grant package cursor"), { code: "invalid_cursor" }); - throw err; -} - -function normalizePackageRevokeError( - grantId: string, - err: unknown -): { grant_id: string; error: { code: string; message: string } } { - const e = err as Record<string, unknown>; - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - const code = isNonEmptyString(e?.code) ? (e.code as string) : "revoke_failed"; - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - const message = isNonEmptyString(e?.message) ? (e.message as string) : "Child grant revoke failed"; - return { error: { code, message }, grant_id: grantId }; -} - -// --------------------------------------------------------------------------- -// Factory — creates bound lifecycle methods; call once from auth.js -// --------------------------------------------------------------------------- - -/** - * Create the grant-package lifecycle object. Auth.js calls this once at - * module load time, passing auth-internal helpers as dependencies. The - * returned methods are re-exported from auth.js so existing route consumers - * keep working without any import changes. - */ -export function createGrantPackageLifecycle(deps: GrantPackageLifecycleDeps): GrantPackageLifecycle { - const { - persistGrant, - issueToken, - revokeGrant, - resolveOAuthClient, - normalizePendingGrantRequest, - requireStructuredPendingRequestShape, - requirePendingRequestClientRegistration, - requireStructuredPendingRequestBindings, - requireGrantManifestForBindings, - resolveGrantSelection, - buildClientDisplayFromRegistration, - describeSourceBinding, - normalizeStorageBinding, - serializeStorageBinding, - describeGrantSource, - requirePersistedGrantState, - buildOAuthAuthorizationCodeError, - } = deps; - - // ------------------------------------------------------------------------- - // Internal helpers that use injected deps - // ------------------------------------------------------------------------- - - async function issuePackageToken( - packageId: string, - subjectId: string, - clientId: string, - expiresAt: string | null = null, - meta: Record<string, unknown> = {} - ): Promise<string> { - const tokenId = generateId("tok"); - await getGrantPackageStore().insertPackageToken({ - clientId, - expiresAt, - packageId, - subjectId, - tokenId, - }); - - const traceContext = meta.traceContext as SpineTraceContext | undefined; - await emitSpineEvent({ - actor_id: "pdpp_as", - actor_type: "authorization_server", - client_id: clientId, - data: { - grant_package_id: packageId, - issuance_path: (meta.source as string | undefined) ?? "hosted_mcp_package", - token_kind: "mcp_package", - }, - event_type: "token.issued", - object_id: tokenId, - object_type: "token", - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - request_id: traceContext?.request_id ?? null, - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - scenario_id: traceContext?.scenario_id ?? null, - status: "succeeded", - subject_id: subjectId, - subject_type: "subject", - token_id: tokenId, - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - trace_id: traceContext?.trace_id ?? null, - } satisfies SpineEventInput); - - return tokenId; - } - - async function requireValidParentPackageLinkage( - parentPackageId: string | null | undefined, - { clientId, subjectId }: { clientId?: string; subjectId?: string } = {} - ): Promise<NormalizedPackage | null> { - if (parentPackageId === undefined || parentPackageId === null) { - return null; - } - const linkageError = (message: string): Error & { code: string; param: string } => - Object.assign(new Error(message), { - code: "invalid_request", - param: "parent_package_id", - }); - if (!isNonEmptyString(parentPackageId)) { - throw linkageError("parent_package_id must be a non-empty string"); - } - const parent = await getGrantPackageRow(parentPackageId); - if (!parent) { - throw linkageError(`parent_package_id ${parentPackageId} does not exist`); - } - if (isNonEmptyString(clientId) && parent.client_id !== clientId) { - throw linkageError("parent_package_id belongs to a different client; cross-client lineage is not allowed"); - } - if (isNonEmptyString(subjectId) && parent.subject_id !== subjectId) { - throw linkageError("parent_package_id belongs to a different owner; cross-owner lineage is not allowed"); - } - if (parent.status !== "active") { - throw linkageError( - `parent_package_id ${parentPackageId} is ${parent.status}; cannot link to an inactive package` - ); - } - return parent; - } - - function describePackageMemberSource( - grant: Record<string, unknown>, - connectionId: string | null = null, - metadata: Record<string, unknown> | null = null - ): Record<string, unknown> | null { - const source = describeGrantSource(grant); - if (!source) { - return null; - } - return { - ...source, - ...(isNonEmptyString(connectionId) ? { connection_id: connectionId } : {}), - ...(metadata?.display_name ? { display_name: metadata.display_name } : {}), - ...(metadata?.connector_display_name ? { connector_display_name: metadata.connector_display_name } : {}), - }; - } - - function applyPendingRequestStorageBinding(request: PendingRequest, rawStorageBinding: unknown): void { - const selectedStorageBinding = normalizeStorageBinding(rawStorageBinding); - if (selectedStorageBinding) { - request.storage_binding = selectedStorageBinding; - } - } - - function isRawConnectionDisplayName(source: Record<string, unknown> | null): boolean { - return isNonEmptyString(source?.connection_id) && source?.display_name === source?.connection_id; - } - - async function normalizePersistedPackageMemberSource( - source: Record<string, unknown> | null, - { ownerSubjectId = null }: { ownerSubjectId?: string | null } = {} - ): Promise<Record<string, unknown> | null> { - if (!source || typeof source !== "object") { - return source; - } - if (!isRawConnectionDisplayName(source)) { - return source; - } - - const sanitized = { ...source }; - const connectorId = isNonEmptyString(sanitized.id) ? (sanitized.id as string) : null; - if (isNonEmptyString(ownerSubjectId) && connectorId) { - const active = await listActiveBindingsForGrant({ - connectorId, - ownerSubjectId, - }).catch(() => []); - const binding = - (active as Record<string, unknown>[]).find((row) => row.connectorInstanceId === sanitized.connection_id) ?? - null; - const displayName = - (projectBindingForWire(binding as never) as Record<string, unknown> | null)?.display_name ?? null; - if (displayName) { - sanitized.display_name = displayName; - return sanitized; - } - } - - sanitized.display_name = undefined; - return sanitized; - } - - async function persistChildGrantForPackage({ - request, - registeredClient, - subjectId, - sourceBinding, - storageBinding, - manifest, - resolvedStreams, - traceContext, - }: { - request: PendingRequest; - registeredClient: RegisteredClient; - subjectId: string; - sourceBinding: SourceBinding; - storageBinding: StorageBinding | null; - manifest: GrantManifest; - resolvedStreams: readonly Record<string, unknown>[]; - traceContext: SpineTraceContext; - }): Promise<{ grant: Record<string, unknown>; token: string; expiresAt: string | null }> { - const selection = request.selection as Record<string, unknown>; - const client = (request.client as Record<string, unknown> | undefined) ?? {}; - - if (selection.purpose_code === "https://pdpp.dev/purpose/ai_training") { - throw Object.assign(new Error("Hosted MCP package consent does not cover ai_training"), { - code: "invalid_request", - param: "purpose_code", - }); - } - - const grantId = generateId("grt"); - const issuedAt = nowIso(); - const expiresAt = - selection.access_mode === "single_use" ? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() : null; - - const persistedSource = describeSourceBinding(sourceBinding); - const persistedStorageBinding = normalizeStorageBinding(storageBinding); - - const grant: Record<string, unknown> = { - access_mode: selection.access_mode, - client: { - client_id: registeredClient.client_id, - registration_mode: registeredClient.registration_mode || "pre_registered_public", - ...(client.client_display ? { client_display: client.client_display } : {}), - }, - expires_at: expiresAt, - grant_id: grantId, - issued_at: issuedAt, - manifest_version: manifest.version, - purpose_code: selection.purpose_code, - purpose_description: selection.purpose_description, - retention: selection.retention, - source: persistedSource, - streams: resolvedStreams, - subject: { id: subjectId }, - version: "0.1.0", - }; - - await persistGrant({ - accessMode: selection.access_mode as string, - clientId: registeredClient.client_id, - expiresAt, - grantId, - grantJson: JSON.stringify(grant), - issuedAt, - scenarioId: traceContext.scenario_id, - storageBindingJson: serializeStorageBinding(persistedStorageBinding), - subjectId, - traceId: traceContext.trace_id, - }); - - await emitSpineEvent({ - actor_id: "pdpp_as", - actor_type: "authorization_server", - client_id: registeredClient.client_id, - data: { - access_mode: selection.access_mode, - purpose_code: selection.purpose_code, - retention: (selection.retention as unknown) ?? null, - source: describeGrantSource(grant), - stream_names: resolvedStreams.map((stream) => stream.name), - }, - event_type: "grant.issued", - grant_id: grantId, - object_id: grantId, - object_type: "grant", - request_id: traceContext.request_id, - scenario_id: traceContext.scenario_id, - status: "succeeded", - subject_id: subjectId, - subject_type: "subject", - trace_id: traceContext.trace_id, - } satisfies SpineEventInput); - - const token = await issueToken(grantId, subjectId, registeredClient.client_id, expiresAt, { - source: "hosted_mcp_package_child", - traceContext, - }); - - return { expiresAt, grant, token }; - } - - // ------------------------------------------------------------------------- - // Publicly bound lifecycle methods - // ------------------------------------------------------------------------- - - async function createHostedMcpGrantPackage({ - clientId, - authorizationDetails, - storageBindings = [], - connectionIds = [], - sourceMetadata = [], - subjectId = "owner_local", - opts = {}, - }: { - clientId: string; - authorizationDetails: unknown[]; - storageBindings?: Array<{ connector_id: string }>; - connectionIds?: Array<string | null>; - sourceMetadata?: Record<string, unknown>[]; - subjectId?: string; - opts?: Record<string, unknown>; - }): Promise<PackageGrantResult> { - if (!isNonEmptyString(clientId)) { - throw buildOAuthAuthorizationCodeError("invalid_request", "client_id is required"); - } - if (!Array.isArray(authorizationDetails) || authorizationDetails.length === 0) { - throw buildOAuthAuthorizationCodeError("invalid_request", "At least one source must be selected"); - } - - const registeredClient = await resolveOAuthClient(clientId, opts); - if (!registeredClient) { - throw buildOAuthAuthorizationCodeError("invalid_client", "Unknown client_id"); - } - - const packageId = generateId("gpkg"); - const scenarioIdVal = opts.scenarioId; - const traceContext = - typeof scenarioIdVal === "string" ? createTraceContext({ scenarioId: scenarioIdVal }) : createTraceContext(); - const createdAt = nowIso(); - const packageEnvelope: Record<string, unknown> = { - approved_source_count: authorizationDetails.length, - client: { - client_display: buildClientDisplayFromRegistration(registeredClient.metadata), - client_id: clientId, - registration_mode: registeredClient.registration_mode || "pre_registered_public", - }, - package_id: packageId, - source_bounded_child_grants: true, - subject: { id: subjectId }, - version: "reference.mcp_package.v1", - }; - - await getGrantPackageStore().insertPackage({ - approvedAt: createdAt, - clientId, - createdAt, - packageId, - packageJson: JSON.stringify(packageEnvelope), - parentPackageId: null, - scenarioId: traceContext.scenario_id, - subjectId, - traceId: traceContext.trace_id, - }); - - const childGrants: Array<{ - grant: Record<string, unknown>; - token: string; - source: Record<string, unknown> | null; - connection_id: string | null; - }> = []; - - for (const [index, detail] of authorizationDetails.entries()) { - const request = normalizePendingGrantRequest({ authorization_details: [detail], client_id: clientId }, opts); - applyPendingRequestStorageBinding(request, storageBindings[index] ?? null); - requireStructuredPendingRequestShape(request); - request.trace_context = traceContext; - // biome-ignore lint/performance/noAwaitInLoops: Work is intentionally sequential to preserve ordering and state transitions. - const childRegisteredClient = await requirePendingRequestClientRegistration(request, opts); - const { sourceBinding, storageBinding } = requireStructuredPendingRequestBindings(request); - request.source_binding = describeSourceBinding(sourceBinding); - request.storage_binding = normalizeStorageBinding(storageBinding); - const manifest = await requireGrantManifestForBindings(sourceBinding, storageBinding, opts); - request.manifest_version = manifest.version; - const resolvedStreams = resolveGrantSelection(request.selection as Record<string, unknown>, manifest); - const { grant, token } = await persistChildGrantForPackage({ - manifest, - registeredClient: childRegisteredClient, - request, - resolvedStreams, - sourceBinding, - storageBinding, - subjectId, - traceContext, - }); - const connectionId = isNonEmptyString(connectionIds[index] ?? null) ? (connectionIds[index] as string) : null; - const source = describePackageMemberSource( - grant, - connectionId, - (sourceMetadata[index] as Record<string, unknown> | undefined) ?? null - ); - const addedAt = nowIso(); - await getGrantPackageStore().insertPackageMember({ - addedAt, - grantId: grant.grant_id as string, - packageId, - sourceJson: JSON.stringify(source), - tokenId: token, - }); - childGrants.push({ connection_id: connectionId, grant, source, token }); - } - - const packageToken = await issuePackageToken(packageId, subjectId, clientId, null, { - source: "hosted_mcp_package", - traceContext, - }); - - await emitSpineEvent({ - actor_id: "pdpp_as", - actor_type: "authorization_server", - client_id: clientId, - data: { - child_grant_ids: childGrants.map((entry) => entry.grant.grant_id), - sources: childGrants.map((entry) => entry.source), - }, - event_type: "grant_package.issued", - object_id: packageId, - object_type: "grant_package", - request_id: traceContext.request_id, - scenario_id: traceContext.scenario_id, - status: "succeeded", - subject_id: subjectId, - subject_type: "subject", - token_id: packageToken, - trace_id: traceContext.trace_id, - } satisfies SpineEventInput); - - return { - child_grants: childGrants, - package: { - ...packageEnvelope, - child_grants: childGrants.map((entry) => ({ - grant_id: entry.grant.grant_id, - source: entry.source, - })), - }, - package_id: packageId, - token: packageToken, - trace_context: traceContext, - }; - } - - async function getGrantPackageAccess(packageId: string): Promise<GrantPackageAccess | null> { - if (!isNonEmptyString(packageId)) { - return null; - } - const store = getGrantPackageStore(); - const packageRow = await store.getPackageById(packageId); - const grantPackage = normalizePackageRow(packageRow as Record<string, unknown> | null); - if (grantPackage?.status !== "active") { - return null; - } - - const memberRows = (await store.listActiveMembers(packageId)) as MemberRow[]; - - const activeMembers: PackageAccessMember[] = []; - for (const row of memberRows) { - if (row.grant_status !== "active" || row.token_revoked) { - continue; - } - if (row.token_expires_at && new Date(row.token_expires_at).getTime() <= Date.now()) { - continue; - } - let grantState: PersistedGrantState; - try { - grantState = requirePersistedGrantState(row); - } catch { - continue; - } - // biome-ignore lint/performance/noAwaitInLoops: Work is intentionally sequential to preserve ordering and state transitions. - const persistedSource = await normalizePersistedPackageMemberSource( - parsePackageJson(row.source_json) ?? describeGrantSource(grantState.grant), - { ownerSubjectId: grantPackage.subject_id } - ); - activeMembers.push({ - connection_id: (persistedSource?.connection_id as string | null) ?? null, - grant: grantState.grant, - grant_id: row.grant_id, - grant_storage_binding: grantState.storageBinding, - package_id: packageId, - source: persistedSource, - token: row.token_id, - }); - } - - return { members: activeMembers, package: grantPackage }; - } - - async function listGrantPackagesForOwner( - opts: { limit?: number; cursor?: string | null } = {} - ): Promise<GrantPackageListPage> { - const limit = Number.isInteger(opts.limit) && (opts.limit ?? 0) > 0 ? (opts.limit as number) : 50; - const cursor = decodeGrantPackageCursor(opts.cursor); - let rows: Record<string, unknown>[]; - - if (isPostgresStorageBackend()) { - const params: unknown[] = []; - let where = ""; - if (cursor) { - params.push(cursor.created_at, cursor.package_id); - where = "WHERE (gp.created_at < $1 OR (gp.created_at = $1 AND gp.package_id < $2))"; - } - params.push(limit + 1); - const limitPlaceholder = `$${params.length}`; - rows = ( - await postgresQuery( - `SELECT gp.package_id, gp.subject_id, gp.client_id, gp.status, - gp.parent_package_id, gp.trace_id, gp.scenario_id, gp.created_at, gp.approved_at, gp.revoked_at, - (SELECT COUNT(*) FROM grant_package_members gpm - WHERE gpm.package_id = gp.package_id) AS member_count - FROM grant_packages gp - ${where} - ORDER BY gp.created_at DESC, gp.package_id DESC - LIMIT ${limitPlaceholder}`, - params - ) - ).rows as Record<string, unknown>[]; - } else { - const allRows = [ - ...allowUnboundedReadAcknowledged<Record<string, unknown>>( - requireReferenceQuery("authGrantPackagesListAll"), - [] - ), - ]; - rows = cursor - ? allRows.filter( - (row) => - (row.created_at as string) < cursor.created_at || - ((row.created_at as string) === cursor.created_at && (row.package_id as string) < cursor.package_id) - ) - : allRows; - rows = rows.slice(0, limit + 1); - } - - const normalized = rows - .map((row) => { - const pkg = normalizePackageRow(row); - if (!pkg) { - return null; - } - const memberCount = row.member_count === null || row.member_count === undefined ? 0 : Number(row.member_count); - return { - ...pkg, - member_count: Number.isFinite(memberCount) ? memberCount : 0, - }; - }) - .filter((row): row is GrantPackageListEntry => row !== null); - - const data = normalized.slice(0, limit); - const hasMore = normalized.length > limit; - const tail = hasMore ? (data.at(-1) ?? null) : null; - return { - data, - has_more: hasMore, - limit, - next_cursor: tail ? encodeGrantPackageCursor(tail) : null, - }; - } - - async function listActivePackageIdsForClient(clientId: string): Promise<string[]> { - if (!isNonEmptyString(clientId)) { - return []; - } - if (isPostgresStorageBackend()) { - const rows = ( - await postgresQuery( - `SELECT package_id - FROM grant_packages - WHERE client_id = $1 AND status = 'active' - ORDER BY created_at ASC`, - [clientId] - ) - ).rows as Record<string, unknown>[]; - return rows.map((row) => row.package_id).filter((id): id is string => isNonEmptyString(id)); - } - return allowUnboundedReadAcknowledged<Record<string, unknown>>( - requireReferenceQuery("authGrantPackagesListAll"), - [] - ) - .filter((row) => row.client_id === clientId && row.status === "active") - .map((row) => row.package_id) - .filter((id): id is string => isNonEmptyString(id)); - } - - async function getGrantPackageForOwner(packageId: string): Promise<GrantPackageSummaryRow | null> { - if (!isNonEmptyString(packageId)) { - return null; - } - const store = getGrantPackageStore(); - const packageRow = await store.getPackageById(packageId); - const grantPackage = normalizePackageRow(packageRow as Record<string, unknown> | null); - if (!grantPackage) { - return null; - } - - const memberRows = (await store.listAllMembers(packageId)) as MemberRow[]; - - const children = await Promise.all( - memberRows.map(async (row) => ({ - added_at: row.added_at, - grant_id: row.grant_id, - grant_status: (row.grant_status ?? "") as string, - member_status: (row.member_status ?? "") as string, - revoked_at: (row.member_revoked_at as string | null) ?? null, - source: await normalizePersistedPackageMemberSource(parsePackageJson(row.source_json) ?? null, { - ownerSubjectId: grantPackage.subject_id, - }), - })) - ); - - return { - ...grantPackage, - children, - member_count: children.length, - }; - } - - async function listGrantPackagesByParent(packageId: string): Promise<NormalizedPackage[]> { - if (!isNonEmptyString(packageId)) { - return []; - } - if (isPostgresStorageBackend()) { - const rows = ( - await postgresQuery( - `SELECT package_id, subject_id, client_id, status, package_json::text AS package_json, - parent_package_id, trace_id, scenario_id, created_at, approved_at, revoked_at - FROM grant_packages - WHERE parent_package_id = $1 - ORDER BY created_at, package_id`, - [packageId] - ) - ).rows as Record<string, unknown>[]; - return rows.map(normalizePackageRow).filter((p): p is NormalizedPackage => p !== null); - } - const rows = allowUnboundedReadAcknowledged<Record<string, unknown>>( - requireReferenceQuery("authGrantPackagesListAll"), - [] - ); - return rows - .map(normalizePackageRow) - .filter((pkg): pkg is NormalizedPackage => pkg !== null && pkg.parent_package_id === packageId); - } - - async function findCumulativeAccessRoot(start: NormalizedPackage): Promise<NormalizedPackage> { - const visitedUp = new Set<string>(); - let root = start; - while (root.parent_package_id && !visitedUp.has(root.package_id)) { - visitedUp.add(root.package_id); - // biome-ignore lint/performance/noAwaitInLoops: Work is intentionally sequential to preserve ordering and state transitions. - const parent = await getGrantPackageRow(root.parent_package_id); - if (!parent) { - break; - } - if (parent.client_id !== start.client_id || parent.subject_id !== start.subject_id) { - break; - } - root = parent; - } - return root; - } - - async function collectCumulativePackageLineageIds(root: NormalizedPackage): Promise<string[]> { - const lineageIds: string[] = []; - const seen = new Set<string>(); - const queue = [root.package_id]; - while (queue.length > 0) { - const current = queue.shift(); - if (current === undefined || seen.has(current)) { - continue; - } - seen.add(current); - lineageIds.push(current); - // biome-ignore lint/performance/noAwaitInLoops: Work is intentionally sequential to preserve ordering and state transitions. - const childPackages = await listGrantPackagesByParent(current); - for (const child of childPackages) { - if (child.client_id !== root.client_id || child.subject_id !== root.subject_id) { - continue; - } - if (!seen.has(child.package_id)) { - queue.push(child.package_id); - } - } - } - return lineageIds; - } - - async function collectCumulativePackageDetails(lineageIds: readonly string[]): Promise<{ - cumulativeChildren: (PackageChildEntry & { package_id: string })[]; - packages: CumulativeClientAccess["packages"][number][]; - }> { - const packages: CumulativeClientAccess["packages"][number][] = []; - const cumulativeChildren: (PackageChildEntry & { package_id: string })[] = []; - for (const id of lineageIds) { - // biome-ignore lint/performance/noAwaitInLoops: Work is intentionally sequential to preserve ordering and state transitions. - const detail = await getGrantPackageForOwner(id); - if (!detail) { - continue; - } - packages.push({ - approved_at: detail.approved_at, - created_at: detail.created_at, - member_count: detail.member_count, - package_id: detail.package_id, - parent_package_id: detail.parent_package_id, - revoked_at: detail.revoked_at, - status: detail.status, - }); - for (const child of detail.children) { - cumulativeChildren.push({ ...child, package_id: detail.package_id }); - } - } - return { cumulativeChildren, packages }; - } - - async function getCumulativeClientAccessForPackage(packageId: string): Promise<CumulativeClientAccess | null> { - if (!isNonEmptyString(packageId)) { - return null; - } - const start = await getGrantPackageRow(packageId); - if (!start) { - return null; - } - - const root = await findCumulativeAccessRoot(start); - - const clientId = root.client_id; - const subjectId = root.subject_id; - const lineageIds = await collectCumulativePackageLineageIds(root); - const { cumulativeChildren, packages } = await collectCumulativePackageDetails(lineageIds); - - const activeChildren = cumulativeChildren.filter( - (child) => child.grant_status === "active" && child.member_status === "active" - ); - - return { - active_child_count: activeChildren.length, - children: cumulativeChildren, - client_id: clientId, - package_count: packages.length, - packages, - root_package_id: root.package_id, - subject_id: subjectId, - }; - } - - async function getGrantPackageIdForGrant(grantId: string): Promise<string | null> { - if (!isNonEmptyString(grantId)) { - return null; - } - const row = await getGrantPackageStore().getPackageIdForGrant(grantId); - return ((row as Record<string, unknown> | null)?.package_id as string | null) ?? null; - } - - async function revokeGrantPackage( - packageId: string, - context: Record<string, unknown> = {} - ): Promise<GrantPackageRevokeResult> { - const memberRows = await getGrantPackageStore().listActiveMembers(packageId); - const activeMembers = memberRows as MemberRow[]; - const revokedChildGrants: string[] = []; - const notRevokedChildGrants: GrantPackageRevokeResult["not_revoked_child_grants"][number][] = []; - - for (const member of activeMembers) { - if (member.grant_status !== "active") { - continue; - } - try { - // biome-ignore lint/performance/noAwaitInLoops: Work is intentionally sequential to preserve ordering and state transitions. - await revokeGrant(member.grant_id, context); - const childRevokedAt = nowIso(); - await getGrantPackageStore().markMemberRevoked({ - grantId: member.grant_id, - packageId, - revokedAt: childRevokedAt, - }); - revokedChildGrants.push(member.grant_id); - } catch (err) { - notRevokedChildGrants.push(normalizePackageRevokeError(member.grant_id, err)); - } - } - - if (notRevokedChildGrants.length > 0) { - await emitSpineEvent({ - actor_id: "pdpp_as", - actor_type: "authorization_server", - data: { - not_revoked_child_grants: notRevokedChildGrants, - revoked_child_grants: revokedChildGrants, - }, - event_type: "grant_package.revoke_partial", - object_id: packageId, - object_type: "grant_package", - request_id: (context.request_id as string | undefined) ?? null, - scenario_id: (context.scenario_id as string | undefined) ?? null, - status: "failed", - trace_id: (context.trace_id as string | undefined) ?? null, - } satisfies SpineEventInput); - return { - not_revoked_child_grants: notRevokedChildGrants, - package_id: packageId, - revoked_at: null, - revoked_child_grants: revokedChildGrants, - status: "partial_failure", - }; - } - - const now = nowIso(); - await getGrantPackageStore().markPackageRevokedCascade({ packageId, revokedAt: now }); - - await emitSpineEvent({ - actor_id: "pdpp_as", - actor_type: "authorization_server", - data: { revoked_child_grants: revokedChildGrants }, - event_type: "grant_package.revoked", - object_id: packageId, - object_type: "grant_package", - request_id: (context.request_id as string | undefined) ?? null, - scenario_id: (context.scenario_id as string | undefined) ?? null, - status: "succeeded", - trace_id: (context.trace_id as string | undefined) ?? null, - } satisfies SpineEventInput); - - return { - not_revoked_child_grants: [], - package_id: packageId, - revoked_at: now, - revoked_child_grants: revokedChildGrants, - status: "revoked", - }; - } - - async function persistStagedBatchPackage({ - packageId, - subjectId, - registeredClient, - packageEnvelope, - parentPackageId, - traceContext, - createdAt, - resolvedEntries, - }: { - packageId: string; - subjectId: string; - registeredClient: { client_id: string; registration_mode: string }; - packageEnvelope: Record<string, unknown>; - parentPackageId: string | null; - traceContext: SpineTraceContext; - createdAt: string; - resolvedEntries: ReadonlyArray<{ - slice: Record<string, unknown>; - sourceBinding: { kind: string; id: string }; - storageBinding: { connector_id: string } | null; - manifest: { version: string }; - resolvedStreams: readonly Record<string, unknown>[]; - }>; - }): Promise<{ - childGrants: Array<{ - grant: Record<string, unknown>; - token: string; - source: Record<string, unknown> | null; - }>; - packageToken: string; - }> { - await getGrantPackageStore().insertPackage({ - approvedAt: createdAt, - clientId: registeredClient.client_id, - createdAt, - packageId, - packageJson: JSON.stringify(packageEnvelope), - parentPackageId, - scenarioId: traceContext.scenario_id, - subjectId, - traceId: traceContext.trace_id, - }); - - const childGrants: Array<{ - grant: Record<string, unknown>; - token: string; - source: Record<string, unknown> | null; - }> = []; - - for (const resolved of resolvedEntries) { - // biome-ignore lint/performance/noAwaitInLoops: Work is intentionally sequential to preserve ordering and state transitions. - const { grant, token } = await persistChildGrantForPackage({ - manifest: resolved.manifest as GrantManifest, - registeredClient: registeredClient as RegisteredClient, - request: resolved.slice as PendingRequest, - resolvedStreams: resolved.resolvedStreams, - sourceBinding: resolved.sourceBinding as SourceBinding, - storageBinding: resolved.storageBinding as StorageBinding | null, - subjectId, - traceContext, - }); - const source = describePackageMemberSource(grant); - const addedAt = nowIso(); - await getGrantPackageStore().insertPackageMember({ - addedAt, - grantId: grant.grant_id as string, - packageId, - sourceJson: JSON.stringify(source), - tokenId: token, - }); - childGrants.push({ grant, source, token }); - } - - const packageToken = await issuePackageToken(packageId, subjectId, registeredClient.client_id, null, { - source: "batch_consent_package", - traceContext, - }); - - return { childGrants, packageToken }; - } - - return { - createHostedMcpGrantPackage, - getCumulativeClientAccessForPackage, - getGrantPackageAccess, - getGrantPackageForOwner, - getGrantPackageIdForGrant, - issuePackageToken, - listActivePackageIdsForClient, - listGrantPackagesForOwner, - persistStagedBatchPackage, - requireValidParentPackageLinkage, - revokeGrantPackage, - }; -} diff --git a/reference-implementation/server/index.ts b/reference-implementation/server/index.ts index 0bc717c0c..ae949a542 100644 --- a/reference-implementation/server/index.ts +++ b/reference-implementation/server/index.ts @@ -179,6 +179,11 @@ import { renderSurface, } from "./hosted-ui.ts"; import { registerInboxRoutes } from "./inbox.ts"; +import { + authenticateIntrospectionCaller, + createRemoteIntrospector, + type IntrospectionCallerCredentials, +} from "./introspection-http.ts"; import { buildAuthorizationServerMetadata, buildClientEventSubscriptionsCapability, @@ -254,6 +259,7 @@ import { getConnectorDetail, getConnectorSummaryForRoute, getOwnerConnectionDiagnostics, + getPendingApprovalDetail, invalidateConnectorSummariesCache, listConnectorSummaries, listConnectorSummaryPage, @@ -427,6 +433,13 @@ import { semanticIndexBackfillForManifest, supportsDeviceSemanticAttemptDeadline, } from "./search-semantic.ts"; +import { requireSourceDeclaration } from "./source-declaration.ts"; +import type { AcceptedSourceDeclarationRevisionStore } from "./source-declaration-trust/revision-store.ts"; +import { + enforceSourceReadRequest, + projectSourceIntrospectionWireContext, + SourceIntrospectionContextError, +} from "./source-introspection-context.ts"; import { createPostgresAcquisitionBatchStore, createSqliteAcquisitionBatchStore, @@ -650,6 +663,12 @@ interface MutationContext { interface ServerOpts { acceptedCollectorProtocolVersions?: readonly string[]; + /** Internal onboarding handoff. This value is never accepted from the PAR request. */ + acceptedProviderNativeRevision?: { + acceptedRevisionReference: string; + revisionStore: AcceptedSourceDeclarationRevisionStore; + sourceId: string; + } | null; agentConnectTtlMs?: number; agentDiscoveryOrigin?: string | null; asIssuer?: string | null; @@ -673,6 +692,7 @@ interface ServerOpts { connectorInstanceId?: string; connectorPathResolver?: ((connectorId: string, manifest?: ConnectorManifest) => string | null) | null; controller?: Controller | null; + databaseUrl?: string; dbPath?: string; deviceExporterStore?: unknown; dynamicClientRegistrationInitialAccessTokens?: readonly string[]; @@ -681,6 +701,8 @@ interface ServerOpts { hybridRetrievalCapability?: unknown; hybridRetrievalSupported?: boolean; ignoreAmbientPublicUrls?: boolean; + introspectionCallerCredentials?: IntrospectionCallerCredentials; + introspectionFetch?: typeof fetch; isNekoProxyTargetApproved?: | (( descriptor: unknown, @@ -728,7 +750,11 @@ interface ServerOpts { referenceMode?: string | null; referenceOrigin?: string | null; referenceRevision?: string | null; + resolveIntrospectionAudience?: (() => string | null) | null; + resolveIntrospectionIssuer?: (() => string | null) | null; rsInternalUrl?: string | null; + rsIntrospectionCredentials?: IntrospectionCallerCredentials; + rsIntrospectionEndpoint?: string | null; rsPort?: number; rsPublicUrl?: string | null; rsUrl?: string | null; @@ -737,10 +763,12 @@ interface ServerOpts { semanticRetrievalBackend?: unknown; semanticRetrievalCapability?: unknown; semanticRetrievalSupported?: boolean; + sourceDeclarationUri?: string | null; sqliteBusyTimeoutMs?: number; startClientEventDeliveryWorker?: boolean; staticSecretAutoResume?: boolean; staticSecretCredentialProber?: unknown; + storageBackend?: "postgres" | "sqlite"; streamingClearTimeout?: ((handle: unknown) => void) | null; streamingCompanionFactory?: StreamingCompanionFactory | null; streamingLogger?: LoggerLike | null; @@ -754,6 +782,67 @@ interface ServerOpts { webPushSubscriptionStore?: WebPushSubscriptionStore | null; } +function requestSelectsSource(body: unknown, sourceId: string): boolean { + if (!(body && typeof body === "object" && "authorization_details" in body)) { + return false; + } + const details = (body as { authorization_details?: unknown }).authorization_details; + return ( + Array.isArray(details) && + details.some( + (detail) => + detail !== null && + typeof detail === "object" && + "source" in detail && + (detail as { source?: { id?: unknown } }).source?.id === sourceId + ) + ); +} + +function readIntrospectionCredentialsFromEnv(): IntrospectionCallerCredentials | null { + const clientId = process.env.PDPP_RS_INTROSPECTION_CLIENT_ID?.trim(); + const clientSecret = process.env.PDPP_RS_INTROSPECTION_CLIENT_SECRET?.trim(); + if (!(clientId || clientSecret)) { + return null; + } + if (!(clientId && clientSecret)) { + throw new Error("PDPP RS introspection requires both client id and client secret"); + } + return { clientId, clientSecret }; +} + +function generateLocalIntrospectionCredentials(): IntrospectionCallerCredentials { + return { + clientId: `rs-${randomBytes(12).toString("base64url")}`, + clientSecret: randomBytes(32).toString("base64url"), + }; +} + +function resolveIntrospectionCredentials(opts: ServerOpts): IntrospectionCallerCredentials { + const asCredentials = opts.introspectionCallerCredentials; + const rsCredentials = opts.rsIntrospectionCredentials; + if (asCredentials && rsCredentials) { + if ( + asCredentials.clientId !== rsCredentials.clientId || + asCredentials.clientSecret !== rsCredentials.clientSecret + ) { + throw new Error("AS and RS introspection credentials must match when both are configured"); + } + return asCredentials; + } + return ( + asCredentials ?? rsCredentials ?? readIntrospectionCredentialsFromEnv() ?? generateLocalIntrospectionCredentials() + ); +} + +function configuredNativeSourceId(opts: ServerOpts): string | null { + const declaration = opts.nativeManifest?.source_declaration; + if (!(declaration && typeof declaration === "object" && "source" in declaration)) { + return null; + } + const { source } = declaration as { source?: { id?: unknown } }; + return typeof source?.id === "string" && source.id ? source.id : null; +} interface OwnerDeviceAuthStore { approve: (userCode: string, subjectId?: string) => Promise<unknown>; deny: (userCode: string, subjectId?: string) => Promise<void>; @@ -1280,12 +1369,20 @@ function createRequestAbortSignal(req: ReqLike | null | undefined, message: stri }; } -function oauthError(res: ResLike, status: number, code: string, description: string) { +function oauthError( + res: ResLike, + status: number, + code: string, + description: string, + _param?: string | null, + extras?: Readonly<Record<string, unknown>> | null +) { const requestId = ensureRequestId(res); res.status(status).json({ error: code, error_description: description, request_id: requestId, + ...(extras ?? {}), }); } @@ -1695,14 +1792,35 @@ async function rejectMutation(res: ResLike, req: ReqLike, context: MutationConte // ─── Auth middleware ───────────────────────────────────────────────────────── // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This protocol transition owns ordered state invariants that must remain local. -async function requireToken(req: ReqLike, res: ResLike, next: () => void) { +async function requireTokenWithIntrospection( + req: ReqLike, + res: ResLike, + next: () => void, + introspectToken: (token: string) => Promise<TokenInfo> +) { const auth = req.headers?.authorization; if (!auth || typeof auth !== "string" || !auth.startsWith("Bearer ")) { setProtectedResourceMetadataChallenge(res); return pdppError(res, 401, "authentication_error", "Missing Bearer token"); } const token = auth.slice(7); - const info = (await introspect(token)) as TokenInfo; + const info = await introspectToken(token); + if (info.active && info.pdpp_token_kind === "client" && req.params?.stream) { + const requestedConnection = resolveRequestConnectionId(req.query ?? {}).connectionId; + try { + enforceSourceReadRequest(info, { + ...(requestedConnection ? { instance_id: requestedConnection } : {}), + stream: req.params.stream, + }); + } catch (error: unknown) { + if (error instanceof SourceIntrospectionContextError) { + info.active = false; + info.inactive_reason = error.code; + } else { + throw error; + } + } + } if (!info.active) { if (info.trace_id) { setReferenceTraceId(res, info.trace_id); @@ -1755,6 +1873,14 @@ async function requireToken(req: ReqLike, res: ResLike, next: () => void) { if (info.inactive_reason === "grant_invalid") { return pdppError(res, 403, "grant_invalid", "Grant is malformed or no longer valid"); } + if (info.inactive_reason?.startsWith("context.")) { + setProtectedResourceMetadataChallenge(res); + return pdppError(res, 401, info.inactive_reason, "Token introspection failed closed"); + } + if (info.inactive_reason === "authorization_state.unsupported_legacy_shape") { + setProtectedResourceMetadataChallenge(res); + return pdppError(res, 401, info.inactive_reason, "Fresh consent is required"); + } setProtectedResourceMetadataChallenge(res); return pdppError(res, 401, "authentication_error", "Invalid or expired token"); } @@ -2138,10 +2264,16 @@ async function resolveOwnerReadScope(req: ReqLike, opts: ServerOpts = {}) { const nativeManifest = resolveNativeManifest(opts); const nativeStorageBinding = resolveNativeStorageBinding(opts); if (nativeManifest && nativeStorageBinding) { + const configuredSource = buildSourceDescriptor( + (nativeManifest.source_declaration as { source?: { id?: string; kind?: string } } | undefined)?.source ?? null + ); + if (!configuredSource) { + throw Object.assign(new Error("Configured SourceDeclaration source is missing"), { code: "invalid_request" }); + } return { owner_subject_id: getOwnerTokenSubjectId(req), public_scope: "native", - source: { id: nativeManifest.provider_id, kind: "provider_native" }, + source: configuredSource, storage_binding: nativeStorageBinding, }; } @@ -2207,9 +2339,7 @@ function validateNativeConfiguration(opts: ServerOpts = {}) { return null; } - if (!nativeManifest.provider_id) { - throw new Error("Native manifest must include provider_id"); - } + requireSourceDeclaration(nativeManifest.source_declaration); if (nativeManifest.connector_id) { throw new Error("Native manifest must not include connector_id"); } @@ -2456,22 +2586,16 @@ function ownerSubjectIdForBindings(tokenInfo: TokenInfo | null | undefined) { function buildClientSourceDescriptor(tokenInfo: TokenInfo | null | undefined) { const grant = tokenInfo?.grant as Record<string, unknown> | null | undefined; - const grantSource = buildSourceDescriptor((grant?.source as { kind?: string; id?: string } | null) ?? null); - if (grantSource) { - return grantSource; - } - - const storageBinding = resolveGrantStorageBinding(tokenInfo); - if (storageBinding?.connector_id) { - return { id: storageBinding.connector_id as string, kind: "connector" }; - } - return null; + return buildSourceDescriptor((grant?.source as { kind?: string; id?: string } | null) ?? null); } function buildOwnerQuerySourceDescriptor(req: ReqLike, opts: ServerOpts = {}) { const nativeManifest = resolveNativeManifest(opts); - if (nativeManifest?.provider_id) { - return buildSourceDescriptor({ id: nativeManifest.provider_id as string, kind: "provider_native" }); + const configuredSource = buildSourceDescriptor( + (nativeManifest?.source_declaration as { source?: { id?: string; kind?: string } } | undefined)?.source ?? null + ); + if (configuredSource) { + return configuredSource; } const connectorId = resolveSingleConnectorIdQueryValue(req.query?.connector_id); @@ -2517,10 +2641,7 @@ async function resolveOwnerManifestFromScope(ownerScope: Record<string, unknown> ); if (!manifest) { const ownerSource = ownerScope.source as { kind?: string; id?: string } | null | undefined; - const errMsg = - ownerSource?.kind === "provider_native" - ? `Unknown source: { kind: 'provider_native', id: '${ownerSource.id}' }` - : `Unknown connector: ${storageBinding?.connector_id || "unknown"}`; + const errMsg = `Unknown source: ${ownerSource?.id || storageBinding?.connector_id || "unknown"}`; throw Object.assign(new Error(errMsg), { code: "not_found" }); } return { manifest, ownerScope, storageBinding }; @@ -2532,51 +2653,22 @@ async function resolveOwnerManifest(req: ReqLike, opts: ServerOpts = {}) { } async function resolveGrantManifest(tokenInfo: TokenInfo | null | undefined, opts: ServerOpts = {}) { - let storageBinding: StorageBinding | null = resolveGrantStorageBinding(tokenInfo) as StorageBinding | null; - // Only resolve a connector_instance namespace for polyfill connector - // sources. Native provider grants point at synthetic storage bindings - // whose connector_id is not registered in the `connectors` catalog, so - // forcing a connector_instances upsert would FK-fail and surface as - // a 500 instead of the intended client-error rejection downstream. - const tokenGrant = tokenInfo?.grant as Record<string, unknown> | null | undefined; - const grantSourceKind = (tokenGrant?.source as Record<string, unknown> | null | undefined)?.kind; - if (storageBinding?.connector_id && grantSourceKind !== "provider_native") { - try { - const namespace = await resolveOwnerConnectorInstanceNamespace({ - allowDefaultAccount: false, - connectorId: storageBinding.connector_id ?? null, - connectorInstanceId: storageBinding.connector_instance_id ?? null, - connectorInstanceStore: createRequestConnectorInstanceStore(), - displayName: storageBinding.connector_id ?? null, - ownerSubjectId: - ((tokenGrant?.subject as Record<string, unknown> | null | undefined)?.id as string | undefined) || - tokenInfo?.subject_id || - OWNER_AUTH_DEFAULT_SUBJECT_ID, - }); - storageBinding = storageTargetForConnectorNamespace(namespace); - } catch (err) { - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - if ((err as ApiError)?.code === "ambiguous_connector_instance") { - storageBinding = { connector_id: storageBinding.connector_id ?? null }; - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - } else if ((err as ApiError)?.code !== "connector_instance_not_found") { - throw err; - } - } - } + // Client serving is pinned by the persisted storage binding and the grant's + // closed instance_ids. Do not resolve a current connector instance here. + const storageBinding: StorageBinding | null = resolveGrantStorageBinding(tokenInfo) as StorageBinding | null; const source = buildClientSourceDescriptor(tokenInfo); const manifest = await getManifestForStorageBinding( storageBinding as unknown as Parameters<typeof getManifestForStorageBinding>[0], opts ); if (!manifest) { - const errMsg = - source?.kind === "provider_native" - ? `Unknown source: { kind: 'provider_native', id: '${source.id}' }` - : `Unknown connector: ${storageBinding?.connector_id || "unknown"}`; + const errMsg = `Unknown source: ${source?.id || storageBinding?.connector_id || "unknown"}`; throw Object.assign(new Error(errMsg), { code: "not_found" }); } - requireGrantContractAgainstManifest(tokenGrant ?? undefined, manifest); + requireGrantContractAgainstManifest( + tokenInfo?.grant ? (tokenInfo.grant as Record<string, unknown>) : undefined, + manifest + ); return { manifest, source, storageBinding }; } @@ -2622,7 +2714,12 @@ export async function resolveGrantScopedStateGrant(connectorId: string, grantId: const row = isPostgresStorageBackend() ? ( await postgresQuery( - `SELECT grant_json::text AS grant_json, + `SELECT grant_id AS persisted_grant_id, + subject_id AS grant_subject_id, + client_id AS grant_client_id, + access_mode AS grant_access_mode, + expires_at AS grant_expires_at, + grant_json::text AS grant_json, storage_binding_json::text AS storage_binding_json, trace_id, scenario_id FROM grants @@ -2704,10 +2801,6 @@ function buildFreshness(lastUpdated: string | null = null) { return deriveReferenceFreshness({ recordLastUpdatedAt: lastUpdated }); } -function getConnectorRunEvidenceSource(source: { kind?: string; id?: string } | null | undefined) { - return source?.kind === "connector" && typeof source.id === "string" && source.id ? source.id : null; -} - async function getLatestConnectorRunSummary(connectorId: string | null, status: string | null = null) { if (!connectorId) { return null; @@ -2743,13 +2836,13 @@ function getMaximumStalenessSeconds(refreshPolicy: unknown) { } async function getConnectorFreshnessEvidence({ - source, + storageBinding, manifest, }: { - source: { kind?: string; id?: string } | null | undefined; + storageBinding: StorageBinding; manifest: Record<string, unknown> | null | undefined; }) { - const connectorId = getConnectorRunEvidenceSource(source); + const connectorId = storageBinding.connector_id ?? null; const refreshPolicy = getManifestRefreshPolicy(manifest); const [lastRun, lastSuccessfulRun] = await Promise.all([ getLatestConnectorRunSummary(connectorId), @@ -2923,25 +3016,29 @@ function buildFieldCapabilities( ...(declaredType ? { type: declaredType } : {}), ...(declaredRole ? { role: declaredRole } : {}), aggregation: buildFieldAggregationCapabilities(aggregations, field, granted), - exact_filter: buildFieldCapabilityFlag({ - declared: isExactFilterableSchema(schemaObj), - granted, - }), granted, lexical_search: buildFieldCapabilityFlag({ declared: lexicalFields.has(field), granted, }), - range_filter: buildFieldCapabilityFlag({ - declared: Boolean(rangeOperators), - granted, - operators: rangeOperators || undefined, - }), schema: fieldSchema, semantic_search: buildFieldCapabilityFlag({ declared: semanticFields.has(field), granted, }), + ...(streamGrant === null + ? { + exact_filter: buildFieldCapabilityFlag({ + declared: isExactFilterableSchema(schemaObj), + granted, + }), + range_filter: buildFieldCapabilityFlag({ + declared: Boolean(rangeOperators), + granted, + operators: rangeOperators || undefined, + }), + } + : {}), }, ]; }) @@ -2963,29 +3060,65 @@ function buildStreamMetadataEntry({ grantedConnections?: unknown[] | null; manifestStreamNames?: Set<string> | null; }) { + const projectedManifestStream = streamGrant ? projectManifestStreamForGrant(streamGrant) : manifestStream; const expandStreamGrant = streamGrant ? { ...streamGrant, grantStreams } : null; const entry: Record<string, unknown> = { - consent_time_field: manifestStream.consent_time_field, - cursor_field: manifestStream.cursor_field, - expand_capabilities: buildExpandCapabilities(manifestStream, expandStreamGrant, manifestStreamNames), - field_capabilities: buildFieldCapabilities(manifestStream, streamGrant), + consent_time_field: projectedManifestStream.consent_time_field, + cursor_field: projectedManifestStream.cursor_field, + expand_capabilities: buildExpandCapabilities(projectedManifestStream, expandStreamGrant, manifestStreamNames), + field_capabilities: buildFieldCapabilities(projectedManifestStream, streamGrant), freshness: freshness ?? buildFreshness(null), - name: manifestStream.name, + name: projectedManifestStream.name, object: "stream_metadata", - primary_key: normalizePrimaryKey(manifestStream.primary_key), - query: manifestStream.query || {}, - relationships: manifestStream.relationships || [], - schema: manifestStream.schema, - selection: manifestStream.selection, - semantics: manifestStream.semantics, - views: manifestStream.views || [], + primary_key: normalizePrimaryKey(projectedManifestStream.primary_key), + query: projectedManifestStream.query || {}, + relationships: projectedManifestStream.relationships || [], + schema: projectedManifestStream.schema, + selection: projectedManifestStream.selection, + semantics: projectedManifestStream.semantics, + views: projectedManifestStream.views || [], }; + if (streamGrant) { + entry.instance_ids = Array.isArray(streamGrant.instance_ids) ? [...streamGrant.instance_ids] : []; + if (Array.isArray(streamGrant.resources)) { + entry.resources = [...streamGrant.resources]; + } + if (streamGrant.time_constraint && typeof streamGrant.time_constraint === "object") { + entry.time_constraint = structuredClone(streamGrant.time_constraint); + } + } if (Array.isArray(grantedConnections)) { entry.granted_connections = grantedConnections; } return entry; } +function projectManifestStreamForGrant(streamGrant: Record<string, unknown>): Record<string, unknown> { + // Resolved grants retain authorization facts, not live schema definitions. + // Do not copy property schemas, requiredness, $defs, query affordances, or + // relationships from the current declaration into a client projection. + // A future grant shape can retain a typed schema snapshot explicitly; until + // then, empty field schemas are the only honest projection. + const fields = Array.isArray(streamGrant.fields) + ? (streamGrant.fields as unknown[]).filter((field): field is string => typeof field === "string") + : []; + const grantedFields = new Set(fields); + const schema: Record<string, unknown> = { + additionalProperties: false, + properties: Object.fromEntries([...grantedFields].map((field) => [field, {}])), + type: "object", + }; + const timeConstraint = + streamGrant.time_constraint && typeof streamGrant.time_constraint === "object" + ? (streamGrant.time_constraint as Record<string, unknown>) + : null; + const frozenTimeField = typeof timeConstraint?.field === "string" ? timeConstraint.field : undefined; + return { + name: streamGrant.name, + schema, + ...(frozenTimeField ? { consent_time_field: frozenTimeField } : {}), + }; +} // Emit one `expand_capabilities` entry per enabled parent-stream relation (a // `query.expand[]` capability backed by a `relationships[]` declaration), // including relations whose target stream is unreadable under the current @@ -3112,6 +3245,87 @@ function buildStreamDiscoverySummary({ }; } +function mergeStreamSummary( + current: Record<string, unknown> | undefined, + next: Record<string, unknown> +): Record<string, unknown> { + const currentUpdated = typeof current?.last_updated === "string" ? current.last_updated : null; + const nextUpdated = typeof next.last_updated === "string" ? next.last_updated : null; + return { + last_updated: !currentUpdated || (nextUpdated && nextUpdated > currentUpdated) ? nextUpdated : currentUpdated, + name: next.name, + object: "stream", + record_count: Number(current?.record_count || 0) + Number(next.record_count || 0), + }; +} + +async function listSubjectVisibleStreamSummaries({ + storageBinding, + manifest, + grant, + ownerSubjectId, +}: { + source: { kind?: string; id?: string } | null | undefined; + storageBinding: StorageBinding; + manifest: Record<string, unknown>; + grant: Record<string, unknown> | null; + ownerSubjectId: string | null; +}): Promise<Record<string, unknown>[]> { + const manifestStreams = Array.isArray(manifest.streams) ? (manifest.streams as Record<string, unknown>[]) : []; + const effectiveGrant = grant ?? { + streams: manifestStreams.flatMap((stream) => + typeof stream.name === "string" && stream.name ? [{ name: stream.name }] : [] + ), + }; + const grantStreams = Array.isArray(effectiveGrant.streams) + ? (effectiveGrant.streams as Record<string, unknown>[]) + : []; + const summaries = new Map<string, Record<string, unknown>>(); + await Promise.all( + grantStreams.map(async (streamGrant) => { + const streamName = typeof streamGrant.name === "string" ? streamGrant.name : null; + if (!streamName) { + return; + } + let bindings: Array<{ connectorId: string; connectorInstanceId: string }>; + try { + const resolved = await resolveReadRequestBindings({ + grant: effectiveGrant as unknown as Parameters<typeof resolveReadRequestBindings>[0]["grant"], + ownerRead: grant === null, + ...(ownerSubjectId ? { ownerSubjectId } : {}), + requestParams: {}, + storageBinding: storageBinding as unknown as NonNullable< + Parameters<typeof resolveReadRequestBindings>[0]["storageBinding"] + >, + streamName, + }); + bindings = resolved.bindings as Array<{ connectorId: string; connectorInstanceId: string }>; + } catch (error) { + if (error instanceof Error && (error as Error & { code?: string }).code === "connection_not_found") { + return; + } + throw error; + } + const perBinding = await Promise.all( + bindings.map((binding) => + listStreams( + { + connector_id: binding.connectorId, + connector_instance_id: binding.connectorInstanceId, + }, + { streams: [streamGrant] } as unknown as Parameters<typeof listStreams>[1], + manifest as Parameters<typeof listStreams>[2] + ) + ) + ); + for (const summary of perBinding.flat() as unknown as Record<string, unknown>[]) { + summaries.set(streamName, mergeStreamSummary(summaries.get(streamName), summary)); + } + }) + ); + return [...summaries.values()]; +} + async function buildConnectorSchemaItem({ source, storageBinding, @@ -3125,14 +3339,14 @@ async function buildConnectorSchemaItem({ grant?: Record<string, unknown> | null; ownerSubjectId?: string | null; }) { - const connectorId = source?.kind === "connector" ? ((source.id as string | null | undefined) ?? null) : null; - const rawStreamSummaries = grant - ? await listStreams( - storageBinding as unknown as Parameters<typeof listStreams>[0], - grant as unknown as Parameters<typeof listStreams>[1], - manifest as unknown as Parameters<typeof listStreams>[2] - ) - : await listAllStreams(storageBinding as unknown as Parameters<typeof listAllStreams>[0]); + const connectorId = (storageBinding.connector_id as string | null | undefined) ?? null; + const rawStreamSummaries = await listSubjectVisibleStreamSummaries({ + grant, + manifest, + ownerSubjectId, + source, + storageBinding, + }); const streamSummaries = rawStreamSummaries as Record<string, unknown>[]; const summaryByName = new Map(streamSummaries.map((summary) => [summary.name as string, summary])); const grantStreamsArr = Array.isArray(grant?.streams) ? (grant?.streams as Record<string, unknown>[]) : []; @@ -3149,43 +3363,35 @@ async function buildConnectorSchemaItem({ // Streams the loaded manifest declares — lets the expand-capabilities builder // distinguish "target stream not granted" from "target stream unknown". const manifestStreamNames = new Set(manifestStreamsArr.map((stream) => stream.name as string)); - const freshnessEvidence = await getConnectorFreshnessEvidence({ manifest, source }); - - // Look up granted connections once per connector. For polyfill connectors - // we batch a single owner+connector store query and reuse the result for - // every stream entry, narrowing per-stream by `grant.streams[].connection_id` - // when the grant pins a single connection. For provider_native sources we - // omit the field — those grants do not address a connection_id. - // biome-ignore lint/suspicious/noEvolvingTypes: This runtime-untyped boundary requires staged type narrowing. - let activeBindings = null; - if (connectorId && ownerSubjectId) { - activeBindings = await listGrantedConnectionsForStream({ - connectorId, - grantStreamConnectionId: null, - ownerSubjectId, - }); - } - - const streams = visibleStreams.map((manifestStream) => { - const streamName = manifestStream.name as string; - const summary = summaryByName.get(streamName); - const lastUpdated = (summary?.last_updated as string | null | undefined) || null; - const streamGrant = grantStreamByName ? grantStreamByName.get(streamName) || null : null; - let grantedConnections: unknown[] | null = null; - if (activeBindings) { - const pin = (streamGrant?.connection_id as string | null | undefined) || null; - const bindingsArr = activeBindings as unknown as Record<string, unknown>[]; - grantedConnections = pin ? bindingsArr.filter((entry) => entry.connection_id === pin) : bindingsArr; - } - return buildStreamMetadataEntry({ - freshness: buildConnectorAwareFreshness(freshnessEvidence, lastUpdated), - grantedConnections, - grantStreams, - manifestStream, - manifestStreamNames, - streamGrant, - }); - }); + const freshnessEvidence = await getConnectorFreshnessEvidence({ manifest, storageBinding }); + + const streams = await Promise.all( + visibleStreams.map(async (manifestStream) => { + const streamName = manifestStream.name as string; + const summary = summaryByName.get(streamName); + const lastUpdated = (summary?.last_updated as string | null | undefined) || null; + const streamGrant = grantStreamByName ? grantStreamByName.get(streamName) || null : null; + let grantedConnections: unknown[] | null = null; + if (connectorId && ownerSubjectId) { + const authorizedInstanceIds = Array.isArray(streamGrant?.instance_ids) + ? (streamGrant.instance_ids as string[]) + : []; + grantedConnections = await listGrantedConnectionsForStream({ + authorizedInstanceIds: grant ? authorizedInstanceIds : null, + connectorId, + ownerSubjectId, + }); + } + return buildStreamMetadataEntry({ + freshness: buildConnectorAwareFreshness(freshnessEvidence, lastUpdated), + grantedConnections, + grantStreams, + manifestStream, + manifestStreamNames, + streamGrant, + }); + }) + ); const item: Record<string, unknown> = { object: "connector", @@ -3205,20 +3411,22 @@ async function buildConnectorDiscoveryItem({ storageBinding, manifest, grant = null as Record<string, unknown> | null, + ownerSubjectId = null as string | null, }: { source: { kind?: string; id?: string } | null | undefined; storageBinding: StorageBinding; manifest: Record<string, unknown>; grant?: Record<string, unknown> | null; + ownerSubjectId?: string | null; }) { - const connectorId = source?.kind === "connector" ? ((source.id as string | null | undefined) ?? null) : null; - const rawSummaries = grant - ? await listStreams( - storageBinding as unknown as Parameters<typeof listStreams>[0], - grant as unknown as Parameters<typeof listStreams>[1], - manifest as unknown as Parameters<typeof listStreams>[2] - ) - : await listAllStreams(storageBinding as unknown as Parameters<typeof listAllStreams>[0]); + const connectorId = (storageBinding.connector_id as string | null | undefined) ?? null; + const rawSummaries = await listSubjectVisibleStreamSummaries({ + grant, + manifest, + ownerSubjectId, + source, + storageBinding, + }); const streamSummaries = rawSummaries as Record<string, unknown>[]; const summaryByName = new Map(streamSummaries.map((summary) => [summary.name as string, summary])); const manifestStreamsArr = Array.isArray(manifest.streams) ? (manifest.streams as Record<string, unknown>[]) : []; @@ -3228,7 +3436,7 @@ async function buildConnectorDiscoveryItem({ .map((streamGrant) => manifestStreamsArr.find((stream) => stream.name === streamGrant.name)) .filter(Boolean) as Record<string, unknown>[]) : manifestStreamsArr; - const freshnessEvidence = await getConnectorFreshnessEvidence({ manifest, source }); + const freshnessEvidence = await getConnectorFreshnessEvidence({ manifest, storageBinding }); const item: Record<string, unknown> = { object: "connector", @@ -3570,37 +3778,31 @@ async function getVisibleStreamFreshness({ stream: string; manifest: Record<string, unknown>; }) { - const freshnessEvidence = await getConnectorFreshnessEvidence({ manifest, source }); - if (tokenInfo?.pdpp_token_kind === "owner") { - const rawSummaries = await listAllStreams(storageBinding as unknown as Parameters<typeof listAllStreams>[0]); - const summaries = rawSummaries as Record<string, unknown>[]; - const summary = summaries.find((entry) => entry.name === stream); - return buildConnectorAwareFreshness( - freshnessEvidence, - (summary?.last_updated as string | null | undefined) || null - ); - } - - const grantStreams = Array.isArray((tokenInfo?.grant as Record<string, unknown> | null | undefined)?.streams) - ? // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - ((tokenInfo?.grant as Record<string, unknown>)?.streams as Record<string, unknown>[]) - : []; - const streamGrant = grantStreams.find((entry) => entry.name === stream); - if (!streamGrant) { + const freshnessEvidence = await getConnectorFreshnessEvidence({ manifest, storageBinding }); + const grant = + tokenInfo?.pdpp_token_kind === "owner" + ? null + : ((tokenInfo?.grant as Record<string, unknown> | null | undefined) ?? null); + const grantStreams = Array.isArray(grant?.streams) ? (grant.streams as Record<string, unknown>[]) : []; + if (grant && !grantStreams.some((entry) => entry.name === stream)) { throw Object.assign(new Error(`Stream '${stream}' not in grant`), { code: "grant_stream_not_allowed", }) as unknown as ApiError; } - const rawSummaries2 = await listStreams( - storageBinding as unknown as Parameters<typeof listStreams>[0], - { streams: [streamGrant] } as unknown as Parameters<typeof listStreams>[1], - manifest as unknown as Parameters<typeof listStreams>[2] - ); - const summaries2 = rawSummaries2 as unknown as Record<string, unknown>[]; - return buildConnectorAwareFreshness( - freshnessEvidence, - (summaries2[0]?.last_updated as string | null | undefined) || null - ); + const summaries = await listSubjectVisibleStreamSummaries({ + grant: grant + ? { + ...grant, + streams: grantStreams.filter((entry) => entry.name === stream), + } + : null, + manifest, + ownerSubjectId: ownerSubjectIdForBindings(tokenInfo), + source, + storageBinding, + }); + const summary = summaries.find((entry) => entry.name === stream); + return buildConnectorAwareFreshness(freshnessEvidence, (summary?.last_updated as string | null | undefined) || null); } // ─── AS App ───────────────────────────────────────────────────────────────── @@ -3976,7 +4178,10 @@ export function buildAsApp(opts: ServerOpts = {}) { async initiateNativeGrant({ baseUrl, clientId, clientName }) { const nativeManifest = resolveNativeManifest(opts); const nativeStorageBinding = (nativeManifest?.storage_binding || {}) as Record<string, unknown>; - if (!(nativeManifest?.provider_id && nativeStorageBinding.connector_id)) { + const configuredSource = buildSourceDescriptor( + (nativeManifest?.source_declaration as { source?: { id?: string; kind?: string } } | undefined)?.source ?? null + ); + if (!(configuredSource && nativeStorageBinding.connector_id)) { return null; } return consentStore.initiateGrant( @@ -3986,7 +4191,7 @@ export function buildAsApp(opts: ServerOpts = {}) { access_mode: "single_use", purpose_code: "https://pdpp.dev/purpose/personal_assistant", purpose_description: "Delegate scoped personal data access to a local PDPP CLI client.", - source: { id: nativeManifest.provider_id as string, kind: "provider_native" }, + source: configuredSource, streams: [{ name: "*" }], type: "https://pdpp.dev/data-access", }, @@ -4278,11 +4483,18 @@ export function buildAsApp(opts: ServerOpts = {}) { }, } as unknown as Parameters<typeof mountAsDeviceUi>[1]); - // POST /introspect extracted to `server/routes/as-oauth.ts` per OpenSpec - // change `split-reference-server-by-route-family` (§6). Behaviour-preserving: - // same contract metadata, same auth posture (none — public endpoint), - // same response envelope, same status codes. - mountAsIntrospect(app, { introspect, pdppError }); + const introspectionCallerCredentials = opts.introspectionCallerCredentials ?? readIntrospectionCredentialsFromEnv(); + if (!introspectionCallerCredentials) { + throw new Error("AS introspection caller credentials must be configured"); + } + mountAsIntrospect(app, { + authenticateCaller: (authorization) => + authenticateIntrospectionCaller(authorization, introspectionCallerCredentials), + introspect: async (token) => projectSourceIntrospectionWireContext(await introspect(token)), + pdppError, + resolveAudience: opts.resolveIntrospectionAudience ?? (() => opts.rsPublicUrl ?? null), + resolveIssuer: opts.resolveIntrospectionIssuer ?? (() => opts.asIssuer ?? opts.asPublicUrl ?? null), + }); // Spine correlation list / timeline / search routes delegate envelope // assembly to canonical operation modules. Timeline and search remain @@ -4463,6 +4675,7 @@ export function buildAsApp(opts: ServerOpts = {}) { (deleteCimdDocument as (d: string, o: unknown) => unknown)(documentId, options), getCimdDocument: (documentId: string) => getCimdDocument(documentId), getOwnerSubjectId, + getPendingApprovalDetail: (approvalId: string) => getPendingApprovalDetail(approvalId), handleError, listActiveTokensForOwnerClient: (clientId: string, subjectId: string) => listActiveTokensForOwnerClient(clientId, subjectId), @@ -4684,23 +4897,21 @@ export function buildAsApp(opts: ServerOpts = {}) { } catch (err) { // Failed restore is terminal, never a route to a connector resuming // against a phone-shaped shared surface. - try { - // The runtime may still be blocked in its interaction promise. Give - // it a terminal cancelled envelope before aborting the child so a - // failed restore cannot leave a live run waiting indefinitely. - originalRespondToInteraction(runId, { - interaction_id: interactionId, - status: "cancelled", - }); - } finally { - // Same trusted internal resolution as onPresentationRestoreFailure - // above: this is system-initiated teardown of a run already known - // to the controller, not an owner-facing cancel request. - const restoreFailureOwnerSubjectId = controller?.getActiveRunOwnerSubjectId(runId); - if (restoreFailureOwnerSubjectId) { - await originalCancelRun?.(runId, restoreFailureOwnerSubjectId); - } - } + // Same trusted internal resolution as onPresentationRestoreFailure + // above: this is system-initiated teardown of a run already known + // to the controller, not an owner-facing cancel request. Start the + // cancellation before resolving the blocked interaction so the + // runtime cannot record success, but resolve before awaiting the + // cancellation because the connector may need it in order to exit. + const restoreFailureOwnerSubjectId = controller?.getActiveRunOwnerSubjectId(runId); + const cancellation = restoreFailureOwnerSubjectId + ? originalCancelRun?.(runId, restoreFailureOwnerSubjectId) + : undefined; + await originalRespondToInteraction(runId, { + interaction_id: interactionId, + status: "cancelled", + }); + await cancellation; throw err; } finally { await runTargetRegistry.forceUnregister({ interactionId, runId }); @@ -5481,7 +5692,22 @@ export function buildAsApp(opts: ServerOpts = {}) { handleError, initiateGrant: (body: unknown, opts2: unknown) => // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - (consentStore as unknown as Record<string, (...args: unknown[]) => unknown>).initiateGrant?.(body, opts2), + (consentStore as unknown as Record<string, (...args: unknown[]) => unknown>).initiateGrant?.(body, { + ...(opts2 && typeof opts2 === "object" ? opts2 : {}), + ...(opts.acceptedProviderNativeRevision && + requestSelectsSource(body, opts.acceptedProviderNativeRevision.sourceId) + ? { + acceptedRevisionReference: opts.acceptedProviderNativeRevision.acceptedRevisionReference, + acceptedRevisionStore: opts.acceptedProviderNativeRevision.revisionStore, + nativeManifestMode: "fulfillment_only", + } + : {}), + ...(!opts.acceptedProviderNativeRevision && + configuredNativeSourceId(opts) && + requestSelectsSource(body, configuredNativeSourceId(opts) as string) + ? { nativeManifestMode: "local_operator_provisioning" } + : {}), + }), nativeManifest: resolveNativeManifest(opts), resolveBaseUrl: (req: unknown) => resolvePublicUrl(req as Parameters<typeof resolvePublicUrl>[0], explicitAsBaseUrl), @@ -5688,6 +5914,19 @@ function buildRsApp(opts: ServerOpts = {}) { const rsOwnerSubjectId = resolveOwnerAuthPlaceholderConfig(opts).subjectId || OWNER_AUTH_DEFAULT_SUBJECT_ID; const trustedMetadataHosts = opts.trustedMetadataHosts ?? (opts.ignoreAmbientPublicUrls ? null : process.env.PDPP_TRUSTED_HOSTS); + const rsIntrospectionCredentials = opts.rsIntrospectionCredentials ?? readIntrospectionCredentialsFromEnv(); + if (!rsIntrospectionCredentials) { + throw new Error("RS introspection credentials must be configured"); + } + const introspectToken = createRemoteIntrospector({ + ...rsIntrospectionCredentials, + endpoint: opts.rsIntrospectionEndpoint ?? `http://127.0.0.1:${opts.asPort ?? AS_PORT}/introspect`, + expectedAudience: opts.resolveIntrospectionAudience ?? (() => explicitResource ?? null), + expectedIssuer: opts.resolveIntrospectionIssuer ?? (() => opts.asIssuer ?? opts.asPublicUrl ?? null), + ...(opts.introspectionFetch ? { fetchImpl: opts.introspectionFetch } : {}), + }); + const requireToken = (req: ReqLike, res: ResLike, next: () => void) => + requireTokenWithIntrospection(req, res, next, introspectToken); app.use((( req: ReqLike & { headers: Record<string, string | string[] | undefined> }, @@ -5960,6 +6199,7 @@ function buildRsApp(opts: ServerOpts = {}) { }) || null) as { primary: string; note?: string } | null; }, resolveSiblingPublicUrl, + resolveSourceDeclarationUri: () => opts.sourceDeclarationUri ?? null, shouldUseDirectRequestOrigin, trustedMetadataHosts, }; @@ -6539,6 +6779,7 @@ function buildRsApp(opts: ServerOpts = {}) { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This protocol transition owns ordered state invariants that must remain local. export async function startServer(opts: ServerOpts = {}) { + const introspectionCredentials = resolveIntrospectionCredentials(opts); const logger = opts.logger ?? buildLogger({ quiet: !!opts.quiet }); const connectorEnvironmentPolicy = resolveConnectorEnvironmentPolicy(opts); setConnectorSummaryReconcileObservationSink(createConnectorSummaryReconcileObservationSink(logger)); @@ -6959,9 +7200,11 @@ export async function startServer(opts: ServerOpts = {}) { connectorId: string; connectorInstanceId: string; connectionId?: string | null; + data: Record<string, unknown> | null; stream: string; version: number | null; emittedAt: string; + recordKey: string; }): Promise<void> { try { const subs = await listActiveSubscriptions(); @@ -6978,8 +7221,10 @@ export async function startServer(opts: ServerOpts = {}) { connectionId: change.connectionId ?? null, connectorId: change.connectorId, connectorInstanceId: change.connectorInstanceId, + data: change.data, emittedAt: change.emittedAt, ownerSubjectId: changedInstanceOwner, + recordKey: change.recordKey, stream: change.stream, version: Number(change.version) || 0, }, @@ -7025,9 +7270,11 @@ export async function startServer(opts: ServerOpts = {}) { connectorId: string; connectorInstanceId: string; connectionId?: string | null; + data: Record<string, unknown> | null; stream: string; version: number | null; emittedAt: string; + recordKey: string; }) => { const task = enqueueClientEvents(change); clientEventEnqueueTasks.add(task); @@ -7043,6 +7290,7 @@ export async function startServer(opts: ServerOpts = {}) { const asApp = buildAsApp({ acceptedCollectorProtocolVersions: opts.acceptedCollectorProtocolVersions, + acceptedProviderNativeRevision: opts.acceptedProviderNativeRevision, agentConnectTtlMs: opts.agentConnectTtlMs, asIssuer: configuredAsIssuer, asPublicUrl: configuredAsPublicUrl, @@ -7052,6 +7300,7 @@ export async function startServer(opts: ServerOpts = {}) { dynamicClientRegistrationInitialAccessTokens: resolveDynamicClientRegistrationInitialAccessTokens(opts), enableDynamicClientRegistration: resolveDynamicClientRegistrationEnabled(opts), ignoreAmbientPublicUrls, + introspectionCallerCredentials: introspectionCredentials, isNekoProxyTargetApproved: opts.isNekoProxyTargetApproved, makePresentationAttachmentId: opts.makePresentationAttachmentId, makeStreamingBrowserSessionId: opts.makeStreamingBrowserSessionId, @@ -7073,6 +7322,8 @@ export async function startServer(opts: ServerOpts = {}) { providerName, publicDynamicClientRegistrationRateLimit: opts.publicDynamicClientRegistrationRateLimit, referenceRevision: opts.referenceRevision, + resolveIntrospectionAudience: () => configuredRsPublicUrl || runtimeContext.rsUrl, + resolveIntrospectionIssuer: () => configuredAsIssuer || runtimeContext.referenceBaseUrl, staticSecretCredentialProber, streamingClearTimeout: opts.streamingClearTimeout, streamingCompanionFactory: opts.streamingCompanionFactory, @@ -7147,6 +7398,7 @@ export async function startServer(opts: ServerOpts = {}) { // builder. hybridRetrievalSupported: opts.hybridRetrievalSupported, ignoreAmbientPublicUrls, + introspectionFetch: opts.introspectionFetch, lexicalRetrievalCapability: opts.lexicalRetrievalCapability, // Lexical retrieval extension knobs — see search.js + the metadata route. lexicalRetrievalSupported: opts.lexicalRetrievalSupported, @@ -7160,11 +7412,15 @@ export async function startServer(opts: ServerOpts = {}) { onScheduleMutation: () => schedulerManager?.refresh(), providerName, referenceRevision: opts.referenceRevision, + resolveIntrospectionAudience: () => configuredRsPublicUrl || runtimeContext.rsUrl, + resolveIntrospectionIssuer: () => configuredAsIssuer || runtimeContext.referenceBaseUrl, // Explicitly-configured internal RS base for the hosted-MCP adapter's // child-grant self-calls (null when only the bare default would apply, so // the adapter falls back to the public resource). See explicitRsInternalUrl. // Spec: openspec/changes/route-hosted-mcp-adapter-self-calls-internally/ rsInternalUrl: explicitRsInternalUrl, + rsIntrospectionCredentials: introspectionCredentials, + rsIntrospectionEndpoint: opts.rsIntrospectionEndpoint ?? `http://127.0.0.1:${asPort}/introspect`, rsPublicUrl: configuredRsPublicUrl, semanticRetrievalCapability: opts.semanticRetrievalCapability, // Semantic retrieval experimental extension knobs — see search-semantic.js @@ -7172,6 +7428,7 @@ export async function startServer(opts: ServerOpts = {}) { // configs reach both the route registration gate and the advertisement // builder. semanticRetrievalSupported: opts.semanticRetrievalSupported, + sourceDeclarationUri: opts.sourceDeclarationUri, trustedMetadataHosts, } as unknown as ServerOpts); const rsServer = await rsApp.listen(requestedRsPort, bindHost); diff --git a/reference-implementation/server/introspection-http.ts b/reference-implementation/server/introspection-http.ts new file mode 100644 index 000000000..19ba715a7 --- /dev/null +++ b/reference-implementation/server/introspection-http.ts @@ -0,0 +1,268 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { createHash, timingSafeEqual } from "node:crypto"; +import { resolveSourceIntrospectionContext, SourceIntrospectionContextError } from "./source-introspection-context.ts"; + +const BASIC_AUTHORIZATION_PATTERN = /^Basic\s+([^\s]+)$/i; + +export interface IntrospectionCallerCredentials { + readonly clientId: string; + readonly clientSecret: string; +} + +export interface RemoteIntrospectionConfig extends IntrospectionCallerCredentials { + readonly endpoint: string; + readonly expectedAudience: () => string | null; + readonly expectedIssuer: () => string | null; + readonly fetchImpl?: typeof fetch; + readonly now?: () => number; +} + +export type RemoteIntrospectionInfo = Record<string, unknown> & { + active: boolean; + inactive_reason?: string; +}; + +function secretDigest(value: string): Buffer { + return createHash("sha256").update(value).digest(); +} + +function secretsEqual(left: string, right: string): boolean { + return timingSafeEqual(secretDigest(left), secretDigest(right)); +} + +function parseBasicCredentials(authorization: string | undefined): IntrospectionCallerCredentials | null { + const match = BASIC_AUTHORIZATION_PATTERN.exec(authorization ?? ""); + if (!match?.[1]) { + return null; + } + let decoded: string; + try { + decoded = Buffer.from(match[1], "base64").toString("utf8"); + } catch { + return null; + } + const separator = decoded.indexOf(":"); + if (separator <= 0) { + return null; + } + return { + clientId: decoded.slice(0, separator), + clientSecret: decoded.slice(separator + 1), + }; +} + +export function authenticateIntrospectionCaller( + authorization: string | undefined, + expected: IntrospectionCallerCredentials +): boolean { + const presented = parseBasicCredentials(authorization); + return !!( + presented && + secretsEqual(presented.clientId, expected.clientId) && + secretsEqual(presented.clientSecret, expected.clientSecret) + ); +} + +export function basicIntrospectionAuthorization(credentials: IntrospectionCallerCredentials): string { + return `Basic ${Buffer.from(`${credentials.clientId}:${credentials.clientSecret}`, "utf8").toString("base64")}`; +} + +function inactive(reason: string): RemoteIntrospectionInfo { + return { active: false, inactive_reason: reason }; +} + +function inactiveWhen(valid: boolean, reason: string): RemoteIntrospectionInfo | null { + return valid ? null : inactive(reason); +} + +function firstInactive(results: readonly (RemoteIntrospectionInfo | null)[]): RemoteIntrospectionInfo | null { + return results.find((result): result is RemoteIntrospectionInfo => result !== null) ?? null; +} + +function audienceMatches(value: unknown, expected: string): boolean { + return typeof value === "string" && value === expected; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function requireClientIdentity(info: Record<string, unknown>): RemoteIntrospectionInfo | null { + return inactiveWhen( + isNonEmptyString(info.client_id) && isNonEmptyString(info.subject_id) && isNonEmptyString(info.grant_id), + "context.identity_mismatch" + ); +} + +function validateClientAuthorizationContext(info: Record<string, unknown>): RemoteIntrospectionInfo | null { + const identityFailure = requireClientIdentity(info); + if (identityFailure) { + return identityFailure; + } + try { + Object.assign(info, resolveSourceIntrospectionContext(info)); + return null; + } catch (error: unknown) { + return inactive(error instanceof SourceIntrospectionContextError ? error.code : "context.rights_missing"); + } +} + +function requirePackageIdentity(info: Record<string, unknown>): RemoteIntrospectionInfo | null { + return inactiveWhen( + isNonEmptyString(info.client_id) && isNonEmptyString(info.subject_id) && isNonEmptyString(info.grant_package_id), + "context.identity_mismatch" + ); +} + +function grantPackage(info: Record<string, unknown>): Record<string, unknown> | null { + return isRecord(info.package) ? info.package : null; +} + +function packageIdentityMatches(value: Record<string, unknown> | null, info: Record<string, unknown>): boolean { + const client = isRecord(value?.client) ? value.client.client_id : null; + const subject = isRecord(value?.subject) ? value.subject.id : null; + return client === info.client_id && subject === info.subject_id; +} + +function validatePackageAuthorizationContext(info: Record<string, unknown>): RemoteIntrospectionInfo | null { + const value = grantPackage(info); + return firstInactive([ + requirePackageIdentity(info), + inactiveWhen(value !== null, "context.rights_missing"), + inactiveWhen(packageIdentityMatches(value, info), "context.identity_mismatch"), + inactiveWhen(value?.package_id === info.grant_package_id, "context.grant_mismatch"), + ]); +} + +function validateOwnerAuthorizationContext(info: Record<string, unknown>): RemoteIntrospectionInfo | null { + return isNonEmptyString(info.subject_id) ? null : inactive("context.identity_mismatch"); +} + +function validateResponseLifetime(info: Record<string, unknown>, nowSeconds: number): RemoteIntrospectionInfo | null { + const isFutureOrAbsent = (value: unknown) => + value === null || + value === undefined || + (typeof value === "number" && Number.isFinite(value) && value > nowSeconds); + return firstInactive([ + inactiveWhen(isFutureOrAbsent(info.exp), "context.expired"), + inactiveWhen(isFutureOrAbsent(info.cache_expires_at), "context.cache_stale"), + ]); +} + +function validateIssuer( + info: Record<string, unknown>, + config: RemoteIntrospectionConfig +): RemoteIntrospectionInfo | null { + const expectedIssuer = config.expectedIssuer(); + return inactiveWhen(Boolean(expectedIssuer && info.iss === expectedIssuer), "context.issuer_mismatch"); +} + +function validateAudience( + info: Record<string, unknown>, + config: RemoteIntrospectionConfig +): RemoteIntrospectionInfo | null { + const expectedAudience = config.expectedAudience(); + return inactiveWhen( + Boolean(expectedAudience && audienceMatches(info.aud, expectedAudience)), + "context.audience_mismatch" + ); +} + +type AuthorizationContextValidator = (info: Record<string, unknown>) => RemoteIntrospectionInfo | null; + +const AUTHORIZATION_CONTEXT_VALIDATORS: Readonly<Record<string, AuthorizationContextValidator>> = { + client: validateClientAuthorizationContext, + mcp_package: validatePackageAuthorizationContext, + owner: validateOwnerAuthorizationContext, +}; + +function validateAuthorizationContext(info: Record<string, unknown>): RemoteIntrospectionInfo | null { + return (AUTHORIZATION_CONTEXT_VALIDATORS[String(info.pdpp_token_kind)] ?? (() => inactive("context.kind_mismatch")))( + info + ); +} + +function inactiveResponse(info: Record<string, unknown>): RemoteIntrospectionInfo { + return { + ...info, + active: false, + inactive_reason: isNonEmptyString(info.inactive_reason) ? info.inactive_reason : "context.active_false", + }; +} + +function validateActiveResponse( + info: Record<string, unknown>, + config: RemoteIntrospectionConfig +): RemoteIntrospectionInfo { + if (info.active !== true) { + return inactiveResponse(info); + } + return ( + firstInactive([ + validateIssuer(info, config), + validateAudience(info, config), + validateResponseLifetime(info, (config.now?.() ?? Date.now()) / 1000), + validateAuthorizationContext(info), + ]) ?? { ...info, active: true } + ); +} + +function requireSuccessfulResponse(response: Response): Response { + if (!response.ok) { + throw new Error("Introspection request failed"); + } + return response; +} + +function requireIntrospectionPayload(payload: unknown): Record<string, unknown> { + if (!(payload && typeof payload === "object" && !Array.isArray(payload))) { + throw new Error("Introspection response must be an object"); + } + return payload as Record<string, unknown>; +} + +function requestIntrospectionPayload({ + authorization, + endpoint, + fetchImpl, + token, +}: { + authorization: string; + endpoint: string; + fetchImpl: typeof fetch; + token: string; +}): Promise<Record<string, unknown> | null> { + return Promise.resolve() + .then(() => + fetchImpl(endpoint, { + body: new URLSearchParams({ token }).toString(), + headers: { + Accept: "application/json", + Authorization: authorization, + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }) + ) + .then(requireSuccessfulResponse) + .then((response) => response.json()) + .then(requireIntrospectionPayload) + .catch(() => null); +} + +export function createRemoteIntrospector( + config: RemoteIntrospectionConfig +): (token: string) => Promise<RemoteIntrospectionInfo> { + const fetchImpl = config.fetchImpl ?? fetch; + const authorization = basicIntrospectionAuthorization(config); + return async (token: string) => { + const payload = await requestIntrospectionPayload({ authorization, endpoint: config.endpoint, fetchImpl, token }); + return payload ? validateActiveResponse(payload, config) : inactive("context.authentication_failed"); + }; +} diff --git a/reference-implementation/server/manifest-resolution.ts b/reference-implementation/server/manifest-resolution.ts index 9e42380a5..b016e5da2 100644 --- a/reference-implementation/server/manifest-resolution.ts +++ b/reference-implementation/server/manifest-resolution.ts @@ -26,10 +26,6 @@ function errorCode(error: unknown): string | undefined { return error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined; } -function isRecord(value: unknown): value is Record<string, unknown> { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - export async function resolveOwnerManifestFromScope(ownerScope: OwnerScope, opts: SourceDescriptorOptions = {}) { let storageBinding = ownerScope.storage_binding || null; if (ownerScope.public_scope === "polyfill" && storageBinding?.connector_id) { @@ -71,11 +67,7 @@ export async function resolveOwnerManifestFromScope(ownerScope: OwnerScope, opts const manifest = await getManifestForStorageBinding(manifestStorageBinding, manifestOptions); if (!manifest) { const err = Object.assign( - new Error( - ownerScope.source.kind === "provider_native" - ? `Unknown source: { kind: 'provider_native', id: '${ownerScope.source.id}' }` - : `Unknown connector: ${storageBinding?.connector_id || "unknown"}` - ), + new Error(`Unknown source: ${ownerScope.source.id || storageBinding?.connector_id || "unknown"}`), { code: "not_found" } ); throw err; @@ -92,43 +84,10 @@ export async function resolveGrantManifest( tokenInfo: TokenInfo | null | undefined, opts: SourceDescriptorOptions = {} ) { - let storageBinding = resolveGrantStorageBinding(tokenInfo); - // Only resolve a connector_instance namespace for polyfill connector - // sources. Native provider grants point at synthetic storage bindings - // whose connector_id is not registered in the `connectors` catalog, so - // forcing a connector_instances upsert would FK-fail and surface as - // a 500 instead of the intended client-error rejection downstream. - const grantSource = tokenInfo?.grant?.source; - const grantSourceKind = isRecord(grantSource) && grantSource.kind === "provider_native" ? "provider_native" : null; - if (storageBinding?.connector_id && grantSourceKind !== "provider_native") { - try { - const namespace = await resolveOwnerConnectorInstanceNamespace({ - // Client/grant reads are also side-effect-free. A grant naming an - // unconnected connector must not create a default-account connection - // simply because the client inspected schema or streams. - allowDefaultAccount: false, - connectorId: storageBinding.connector_id, - ...(storageBinding.connector_instance_id ? { connectorInstanceId: storageBinding.connector_instance_id } : {}), - connectorInstanceStore: createRequestConnectorInstanceStore(), - displayName: storageBinding.connector_id, - ownerSubjectId: tokenInfo?.grant?.subject?.id || tokenInfo?.subject_id || OWNER_AUTH_DEFAULT_SUBJECT_ID, - }); - storageBinding = storageTargetForConnectorNamespace(namespace); - } catch (err: unknown) { - // Tolerate multi-connection ambiguity: the route layer fans in over - // every active connection under the connector. The storage binding - // stays scoped to `connector_id` only; the route uses the - // fan-in resolver to pick / iterate concrete bindings. - if (errorCode(err) === "ambiguous_connector_instance") { - storageBinding = { connector_id: storageBinding.connector_id }; - } else if (errorCode(err) !== "connector_instance_not_found") { - // If the connector is not registered, fall through to the - // manifest-not-found path below so the route returns a clean 404 - // ("Unknown connector: …") instead of bubbling a 500. - throw err; - } - } - } + // The persisted storage binding and closed per-stream instance_ids are the + // serving authority. Do not re-resolve a current instance or dispatch on + // source.kind: kind is retained provenance, not a runtime type. + const storageBinding = resolveGrantStorageBinding(tokenInfo); const source: SourceDescriptor | null = buildClientSourceDescriptor(tokenInfo); const manifestOptions = opts.nativeManifest === undefined @@ -136,12 +95,9 @@ export async function resolveGrantManifest( : { nativeManifest: opts.nativeManifest as unknown as Record<string, unknown> }; const manifest = await getManifestForStorageBinding(storageBinding, manifestOptions); if (!manifest) { - const err = Object.assign( - source?.kind === "provider_native" - ? new Error(`Unknown source: { kind: 'provider_native', id: '${source.id}' }`) - : new Error(`Unknown connector: ${storageBinding?.connector_id || "unknown"}`), - { code: "not_found" } - ); + const err = Object.assign(new Error(`Unknown source: ${source?.id || storageBinding?.connector_id || "unknown"}`), { + code: "not_found", + }); throw err; } requireGrantContractAgainstManifest(tokenInfo?.grant, manifest); diff --git a/reference-implementation/server/metadata.ts b/reference-implementation/server/metadata.ts index 9b3b6fd28..2c2c023d3 100644 --- a/reference-implementation/server/metadata.ts +++ b/reference-implementation/server/metadata.ts @@ -12,6 +12,7 @@ // fixtures used in conformance tests. import { isIP } from "node:net"; +import { validateProviderNativeDiscoveryMetadata } from "@pdpp/reference-contract"; // Lightweight Express-like accessors. We don't import express types // directly here because the helper is also called from Fastify and @@ -745,6 +746,8 @@ export interface ProtectedResourceMetadataInput { resource: string; resourceName: string; selfExportSupported: boolean; + /** Provider-native only. The contract validator checks the exact resource binding and URI shape. */ + sourceDeclarationUri?: string | null; tokenKindsSupported: readonly string[]; } @@ -758,6 +761,7 @@ export interface ProtectedResourceMetadata { pdpp_owner_agent_onboarding?: ProtectedResourceOwnerAgentOnboarding; pdpp_provider_connect_version: string; pdpp_self_export_supported: boolean; + pdpp_source_declaration_uri?: string; pdpp_token_kinds_supported: readonly string[]; resource: string; resource_name: string; @@ -771,6 +775,7 @@ export function buildProtectedResourceMetadata({ providerConnectVersion, selfExportSupported, tokenKindsSupported, + sourceDeclarationUri, agentDiscovery, ownerAgentOnboarding, capabilities, @@ -795,6 +800,16 @@ export function buildProtectedResourceMetadata({ if (ownerAgentOnboarding) { metadata.pdpp_owner_agent_onboarding = ownerAgentOnboarding; } + if (sourceDeclarationUri !== null && sourceDeclarationUri !== undefined) { + const validation = validateProviderNativeDiscoveryMetadata(resource, { + pdpp_source_declaration_uri: sourceDeclarationUri, + resource, + }); + if (!validation.ok) { + throw new TypeError(`Invalid provider-native source declaration pointer: ${validation.reason}`); + } + metadata.pdpp_source_declaration_uri = validation.sourceDeclarationUri; + } if (capabilities && typeof capabilities === "object" && Object.keys(capabilities).length > 0) { metadata.capabilities = capabilities; } diff --git a/reference-implementation/server/package-rs-client.ts b/reference-implementation/server/package-rs-client.ts index 0c88a1e0e..b989d91a5 100644 --- a/reference-implementation/server/package-rs-client.ts +++ b/reference-implementation/server/package-rs-client.ts @@ -47,6 +47,7 @@ import type { ConnectorSchemaItem } from "../operations/rs-schema-get/index.ts"; const AMBIGUOUS_CONNECTION_LIST_LIMIT = 12; const EVENT_SUB_PATH_PATTERN = /^\/v1\/event-subscriptions\/([^/]+)$/; const PARSABLE_POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/; +const STREAM_PATH_PATTERN = /^\/v1\/streams\/([^/]+)/; const TEST_EVENT_PATH_PATTERN = /^\/v1\/event-subscriptions\/([^/]+)\/test-event$/; const TRAILING_SLASH_PATTERN = /\/$/; @@ -55,8 +56,8 @@ type JsonRow = JsonObject; type PackageRsResponse = RsResponse<unknown>; type PackageRsFetch = typeof globalThis.fetch; interface PackageRsMember { - connection_id: string; - grant?: { streams?: Array<{ name?: string }> }; + connection_id?: string | null; + grant?: { streams?: Array<{ instance_ids?: string[]; name?: string }> }; grant_id: string; source?: { display_name?: string; id?: string; [key: string]: unknown }; token: string; @@ -309,19 +310,18 @@ class PackageRsClient { async fanoutSchema({ query, headers }: RequestOptions): Promise<PackageRsResponse> { if (query?.connection_id) { - const scoped = pickChildByConnectionId(this.children, query.connection_id); - if (!scoped) { - return typedError( - "not_found", - `connection_id "${query.connection_id}" is not part of this package`, - this.children - ); + const selected = this.selectChildOrError(query.connection_id, { + sourceId: sourceIdFromQuery(query), + streamNames: queryStringValues(query.stream), + }); + if ("error" in selected) { + return selected.error; } - const result = await scoped.client.getJson( + const result = await selected.child.client.getJson( "/v1/schema", - rsRequestOptions({ headers, query: stripConnectionId(query) }) + rsRequestOptions({ headers, query: stripPackageSelectors(query) }) ); - return mergeSchemaEnvelopes([scoped], [result]); + return mergeSchemaEnvelopes([selected.child], [result]); } if (query?.detail === "full") { @@ -391,15 +391,16 @@ class PackageRsClient { async fanoutStreams({ query, headers }: RequestOptions): Promise<PackageRsResponse> { // If caller scoped to one connection_id, route to that child only. if (query?.connection_id) { - const scoped = pickChildByConnectionId(this.children, query.connection_id); - if (!scoped) { - return typedError( - "not_found", - `connection_id "${query.connection_id}" is not part of this package`, - this.children - ); + const selected = this.selectChildOrError(query.connection_id, { + sourceId: sourceIdFromQuery(query), + }); + if ("error" in selected) { + return selected.error; } - return scoped.client.getJson("/v1/streams", rsRequestOptions({ headers, query })); + return selected.child.client.getJson( + "/v1/streams", + rsRequestOptions({ headers, query: stripPackageSelectors(query) }) + ); } const results = await Promise.all( @@ -410,15 +411,14 @@ class PackageRsClient { async fanoutSearch(path: string, { query, headers }: RequestOptions): Promise<PackageRsResponse> { if (query?.connection_id) { - const scoped = pickChildByConnectionId(this.children, query.connection_id); - if (!scoped) { - return typedError( - "not_found", - `connection_id "${query.connection_id}" is not part of this package`, - this.children - ); + const selected = this.selectChildOrError(query.connection_id, { + sourceId: sourceIdFromQuery(query), + streamNames: requestedStreamsFromQuery(query), + }); + if ("error" in selected) { + return selected.error; } - return scoped.client.getJson(path, rsRequestOptions({ headers, query })); + return selected.child.client.getJson(path, rsRequestOptions({ headers, query: stripPackageSelectors(query) })); } const results = await Promise.all( @@ -438,7 +438,7 @@ class PackageRsClient { path: string, opts: RequestOptions ): Promise<PackageRsResponse> { - const child = await this.resolveChildOrError(opts); + const child = await this.resolveChildOrError(path, opts); if ("error" in child) { return child.error; } @@ -452,27 +452,27 @@ class PackageRsClient { } else { clientMethod = "deleteJson"; } - return child.child.client[clientMethod](path, rsRequestOptions(opts)); + return child.child.client[clientMethod]( + path, + rsRequestOptions({ ...opts, query: stripPackageSelectors(opts.query) }) + ); } async sourceRequiredRaw(_method: "GET", path: string, opts: RequestOptions): Promise<PackageRsResponse> { - const child = await this.resolveChildOrError(opts); + const child = await this.resolveChildOrError(path, opts); if ("error" in child) { return child.error; } - return child.child.client.getRaw(path, rsRequestOptions(opts)); + return child.child.client.getRaw(path, rsRequestOptions({ ...opts, query: stripPackageSelectors(opts.query) })); } - async resolveChildOrError({ query }: RequestOptions): Promise<ChildLookup> { + async resolveChildOrError(path: string, { query }: RequestOptions): Promise<ChildLookup> { const connectionId = query?.connection_id; if (connectionId) { - const child = pickChildByConnectionId(this.children, connectionId); - if (child) { - return { child }; - } - return { - error: typedError("not_found", `connection_id "${connectionId}" is not part of this package`, this.children), - }; + return this.selectChildOrError(connectionId, { + sourceId: sourceIdFromQuery(query), + streamNames: queryStringValues(streamNameFromPath(path)), + }); } if (this.children.length === 1 && this.children[0]) { return { child: this.children[0] }; @@ -488,6 +488,33 @@ class PackageRsClient { return Promise.resolve(typedError("ambiguous_connection", message, this.children)); } + selectChildOrError( + connectionId: QueryValue, + { sourceId = null, streamNames = [] }: { sourceId?: string | null; streamNames?: string[] } = {} + ): ChildLookup { + const matches = matchingChildrenByConnectionId(this.children, connectionId, streamNames, sourceId); + if (matches.length === 1 && matches[0]) { + return { child: matches[0] }; + } + if (matches.length > 1) { + return { + error: typedError( + "ambiguous_connection", + `connection_id "${String(connectionId)}" exists under multiple package sources; pass source_id to select one`, + matches, + { param: "source_id", retryWith: "source_id" } + ), + }; + } + return { + error: typedError( + "not_found", + `connection_id "${String(connectionId)}" is not part of the selected package source and stream`, + this.children + ), + }; + } + // -------- event subscriptions -------- async createEventSubForChild({ body, query, headers }: RequestOptions): Promise<PackageRsResponse> { @@ -497,10 +524,13 @@ class PackageRsClient { (isRecord(body) && typeof body.connection_id === "string" ? body.connection_id : undefined); let child: PackageChild | undefined; if (sel) { - child = pickChildByConnectionId(this.children, sel) ?? undefined; - if (!child) { - return typedError("not_found", `connection_id "${sel}" is not part of this package`, this.children); + const selected = this.selectChildOrError(sel, { + sourceId: sourceIdFromRequest(query, body), + }); + if ("error" in selected) { + return selected.error; } + ({ child } = selected); } else if (this.children.length === 1) { [child] = this.children; } else { @@ -510,12 +540,14 @@ class PackageRsClient { } const childBody: JsonObject = isRecord(body) ? { ...body } : {}; childBody.connection_id = undefined; + childBody.connector_id = undefined; + childBody.source_id = undefined; if (!child) { throw new Error("PackageRsClient has no active children"); } return child.client.postJson( "/v1/event-subscriptions", - rsRequestOptions({ body: childBody, headers, query: stripConnectionId(query) }) + rsRequestOptions({ body: childBody, headers, query: stripPackageSelectors(query, { connectionId: true }) }) ); } @@ -629,21 +661,71 @@ function routeFor(method: string, path: string): Route { // -------- selectors -------- -function pickChildByConnectionId(children: PackageChild[], connectionId: string | QueryValue): PackageChild | null { +function matchingChildrenByConnectionId( + children: PackageChild[], + connectionId: string | QueryValue, + streamNames: string[] = [], + sourceId: string | null = null +): PackageChild[] { if (!connectionId) { - return null; + return []; } - return children.find(({ member }) => member.connection_id === connectionId) || null; + return children.filter( + ({ member }) => + (!sourceId || member.source?.id === sourceId) && grantedInstanceIds(member, streamNames).has(String(connectionId)) + ); +} + +function grantedInstanceIds(member: PackageRsMember, streamNames: string[] = []): Set<string> { + const requestedStreams = new Set(streamNames); + const streams = Array.isArray(member.grant?.streams) ? member.grant.streams : []; + return new Set( + streams + .filter( + (stream) => + requestedStreams.size === 0 || (typeof stream.name === "string" && requestedStreams.has(stream.name)) + ) + .flatMap((stream) => (Array.isArray(stream.instance_ids) ? stream.instance_ids : [])) + .filter((instanceId) => typeof instanceId === "string" && instanceId.length > 0) + ); +} + +function streamNameFromPath(path: string): string | null { + const match = path.split("?")[0]?.match(STREAM_PATH_PATTERN); + return match?.[1] ? decodeURIComponent(match[1]) : null; } -function stripConnectionId(query: QueryParams | undefined): QueryParams | undefined { +function stripPackageSelectors( + query: QueryParams | undefined, + { connectionId = false }: { connectionId?: boolean } = {} +): QueryParams | undefined { if (!query || typeof query !== "object") { return query; } - const { connection_id: _omit, ...rest } = query; + const { connector_id: _connectorId, source_id: _sourceId, ...rest } = query; + if (connectionId) { + rest.connection_id = undefined; + } return rest; } +function sourceIdFromQuery(query: QueryParams | undefined): string | null { + return firstNonEmptyString(query?.source_id, query?.connector_id) ?? null; +} + +function sourceIdFromRequest(query: QueryParams | undefined, body: unknown): string | null { + const requestBody = isRecord(body) ? body : {}; + return ( + firstNonEmptyString(query?.source_id, query?.connector_id, requestBody.source_id, requestBody.connector_id) ?? null + ); +} + +function queryStringValues(value: QueryValue | string | null | undefined): string[] { + const values: string[] = []; + collectStreamQueryValues(values, value as QueryValue); + return values; +} + function searchQueryForChild(query: QueryParams | undefined, member: PackageRsMember): QueryParams | null | undefined { const requested = requestedStreamsFromQuery(query); if (requested.length === 0) { @@ -726,7 +808,7 @@ function emptySearchResponse(): PackageRsResponse { function memberSourceTag(member: PackageRsMember): SourceTag { const connectorKey = member.source?.id ?? null; return { - connection_id: member.connection_id, + connection_id: member.connection_id ?? null, connector_id: connectorKey, connector_key: connectorKey, grant_id: member.grant_id, diff --git a/reference-implementation/server/postgres-records.ts b/reference-implementation/server/postgres-records.ts index a94eab3e9..9e4499cd1 100644 --- a/reference-implementation/server/postgres-records.ts +++ b/reference-implementation/server/postgres-records.ts @@ -32,8 +32,9 @@ import { withPostgresTransaction, } from "./postgres-storage.ts"; import { + assertExpansionInstanceAuthorized, + assertNonEmptyJsonField, assertRecordIdentity, - assertSafeJsonField, buildEffectiveFilter, normalizeExpandRequest, normalizePrimaryKey, @@ -47,7 +48,12 @@ import { fieldWindowError, normalizeWindowSelector, } from "./record-field-window.ts"; -import { compileRequestFilters, nonNullSchemaTypes, passesRequestFilters, passesTimeRange } from "./record-filters.ts"; +import { + compileRequestFilters, + nonNullSchemaTypes, + passesRequestFilters, + passesTimeConstraint, +} from "./record-filters.ts"; import { getChangeHistoryLimit, nowIso, @@ -58,8 +64,6 @@ import { createPostgresConnectorInstanceStore } from "./stores/connector-instanc import { advancePostgresDeviceIngestPrefix } from "./stores/device-exporter-store.ts"; type JsonObject = Record<string, unknown>; -const SAFE_JSON_FIELD = /^[A-Za-z0-9_]+$/; -const RECORD_TIME_FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/; type PostgresAdmissionLockedPhaseHook = (point: string, context: Record<string, unknown>) => Promise<void> | void; let postgresAdmissionLockedPhaseHook: PostgresAdmissionLockedPhaseHook | null = null; @@ -116,9 +120,10 @@ interface ConnectorManifest { interface StreamGrant { fields?: string[] | null; + instance_ids?: string[]; name: string; resources?: string[]; - time_range?: { since?: string; until?: string } | null; + time_constraint?: { field: string; since?: string; until?: string } | null; } interface ConnectorGrant { @@ -140,6 +145,7 @@ interface PgRow { connector_count?: number | string; connector_id?: string; connector_instance_id?: string; + consent_time_type?: string | null; consent_time_value?: string | null; count?: number | string; cursor_value?: string | null; @@ -185,7 +191,8 @@ interface PgRow { interface EffectiveFilter { fields?: string[] | null; resources?: string[] | null; - timeRange?: { since?: string; until?: string } | null; + timeConstraint?: { field: string; since?: string; until?: string } | null; + timeConstraintField?: string | null; } interface CompiledFilter { @@ -577,7 +584,7 @@ function fieldsFor( effective = [...requestFields]; } } - if (effective) { + if (effective && !Object.hasOwn(streamGrant, "instance_ids")) { const seen = new Set(effective); for (const required of requiredFields) { if (!seen.has(required)) { @@ -753,7 +760,7 @@ export async function postgresBackfillRecordCursorValuesForManifest( const streamFacts = manifest.streams .map((manifestStream) => ({ - cursorField: safeJsonField(manifestStream?.cursor_field), + cursorField: nonEmptyJsonField(manifestStream?.cursor_field), stream: typeof manifestStream?.name === "string" ? manifestStream.name : null, })) .filter((facts): facts is { cursorField: string; stream: string } => Boolean(facts.stream && facts.cursorField)); @@ -790,8 +797,8 @@ export async function postgresBackfillRecordSortPositionsForManifest( const streamFacts = manifest.streams .map((manifestStream) => ({ - consentTimeField: safeJsonField(manifestStream?.consent_time_field), - cursorField: safeJsonField(manifestStream?.cursor_field), + consentTimeField: nonEmptyJsonField(manifestStream?.consent_time_field), + cursorField: nonEmptyJsonField(manifestStream?.cursor_field), primaryKey: primaryKeyFieldsFor(manifestStream), stream: typeof manifestStream?.name === "string" ? manifestStream.name : null, })) @@ -1093,7 +1100,7 @@ function buildRangeFilterClauses( const cast = postgresRangeCastForField(filter.fieldSchema); const fieldExpr = jsonStringExpr(filter.field); const lhs = `${fieldExpr}::${cast}`; - const clauses = [`record_json ? '${filter.field}'`, `${fieldExpr} IS NOT NULL`]; + const clauses = [`record_json ? ${postgresJsonKeyLiteral(filter.field)}`, `${fieldExpr} IS NOT NULL`]; for (const op of ["gte", "gt", "lte", "lt"]) { if (!Object.hasOwn(operators, op)) { continue; @@ -1116,7 +1123,7 @@ function buildFilterClause( } const clauses: string[] = []; for (const filter of compiledFilters) { - assertSafeJsonField(filter.field, "filter"); + assertNonEmptyJsonField(filter.field, "filter"); if (filter.kind === "range") { clauses.push(...buildRangeFilterClauses(filter, rawFilter, params)); continue; @@ -1141,24 +1148,17 @@ export function __buildPostgresFilterClauseForTest( }; } -function appendGrantVisibilityClauses( - whereParts: string[], - params: unknown[], - effective: EffectiveFilter, - manifestStream: ManifestStream | null -): void { - const consentTimeField = manifestStream?.consent_time_field || null; - if (effective.timeRange && consentTimeField) { - assertSafeJsonField(consentTimeField, "consent_time_field"); - const ctExpr = jsonStringExpr(consentTimeField); - whereParts.push(`${ctExpr} IS NOT NULL`); - if (effective.timeRange.since !== null && effective.timeRange.since !== undefined) { - params.push(new Date(effective.timeRange.since).toISOString()); - whereParts.push(`${ctExpr} >= $${params.length}`); +function appendGrantVisibilityClauses(whereParts: string[], params: unknown[], effective: EffectiveFilter): void { + if (effective.timeConstraint) { + const grantTimeExpr = postgresGrantTimeExpr(effective.timeConstraint.field); + whereParts.push(`${grantTimeExpr} IS NOT NULL`); + if (effective.timeConstraint.since !== null && effective.timeConstraint.since !== undefined) { + params.push(new Date(effective.timeConstraint.since).toISOString()); + whereParts.push(`${grantTimeExpr} >= $${params.length}::timestamptz`); } - if (effective.timeRange.until !== null && effective.timeRange.until !== undefined) { - params.push(new Date(effective.timeRange.until).toISOString()); - whereParts.push(`${ctExpr} < $${params.length}`); + if (effective.timeConstraint.until !== null && effective.timeConstraint.until !== undefined) { + params.push(new Date(effective.timeConstraint.until).toISOString()); + whereParts.push(`${grantTimeExpr} < $${params.length}::timestamptz`); } } @@ -1168,22 +1168,24 @@ function appendGrantVisibilityClauses( } } -function isVisiblePostgresSnapshot( - snapshot: RecordSnapshot | null, - effective: EffectiveFilter, - consentTimeField: string | null -): boolean { +export function __buildPostgresGrantVisibilityForTest(streamGrant: StreamGrant): { + params: unknown[]; + whereParts: string[]; +} { + const whereParts: string[] = []; + const params: unknown[] = []; + appendGrantVisibilityClauses(whereParts, params, buildEffectiveFilter(streamGrant, {})); + return { params, whereParts }; +} + +function isVisiblePostgresSnapshot(snapshot: RecordSnapshot | null, effective: EffectiveFilter): boolean { if (!snapshot || snapshot.deleted || !snapshot.data) { return false; } if (effective.resources && !effective.resources.includes(snapshot.record_key)) { return false; } - if ( - effective.timeRange && - consentTimeField && - !passesTimeRange(snapshot.data, effective.timeRange, consentTimeField) - ) { + if (!passesTimeConstraint(snapshot.data, effective.timeConstraint)) { return false; } return true; @@ -1223,15 +1225,15 @@ async function getPostgresSnapshotAtVersion( }; } -function safeJsonField(field: unknown): string | null { - if (!(typeof field === "string" && field && SAFE_JSON_FIELD.test(field))) { +function nonEmptyJsonField(field: unknown): string | null { + if (!(typeof field === "string" && field.length > 0)) { return null; } - return field as string; + return field; } function recordOrderExpressions(manifestStream: ManifestStream | null): { cursorSql: string; primarySql: string } { - const cursorField = safeJsonField(manifestStream?.cursor_field); + const cursorField = nonEmptyJsonField(manifestStream?.cursor_field); return { cursorSql: cursorField ? "cursor_value" : "emitted_at", primarySql: "primary_key_text", @@ -1254,11 +1256,24 @@ function rejectExpandWithChangesSince(requestParams: QueryRequestParams): void { } function jsonStringExpr(field: string): string { - // record_json is JSONB on Postgres; `->>` returns the field as text. - // Field comes from the manifest and is re-validated against SAFE_JSON_FIELD - // before reaching this builder, so quoting it as a SQL literal is safe. - assertSafeJsonField(field, "json_string"); - return `(record_json->>'${field}')`; + return postgresTopLevelJsonExpr(field); +} + +function postgresTopLevelJsonExpr(field: string): string { + assertNonEmptyJsonField(field, "json_string"); + const literal = field.replace(/'/g, "''"); + return `(record_json->>'${literal}')`; +} + +function postgresJsonKeyLiteral(field: string): string { + assertNonEmptyJsonField(field, "json_key"); + return `'${field.replace(/'/g, "''")}'`; +} + +function postgresGrantTimeExpr(field: string): string { + const literal = field.replace(/'/g, "''"); + const value = postgresTopLevelJsonExpr(field); + return `(CASE WHEN jsonb_typeof(record_json->'${literal}') = 'string' AND pg_input_is_valid(${value}, 'timestamp with time zone') THEN ${value}::timestamptz END)`; } function childResponseRecord({ @@ -1288,12 +1303,12 @@ function childResponseRecord({ * trip. Children are partitioned by foreign key and ranked by the child * stream's manifest-declared (cursor_field, primary_key) basis so the * per-parent slice and per-parent `has_more` signal match the SQLite - * engine. Grant projection (`fields`, `time_range`, `resources`) is + * engine. Grant projection (`fields`, `time_constraint`, `resources`) is * enforced in SQL exactly as the SQLite path enforces it. * * Throws `invalid_expand` if the child manifest is missing or declares * a child stream whose foreign-key/primary-key fields fail the - * SAFE_JSON_FIELD regex. + * literal top-level JSON-key validation. * * Spec: openspec/changes/add-postgres-expand-hydration/specs/ * reference-implementation-architecture/spec.md @@ -1324,7 +1339,6 @@ function resolvePostgresExpansionFields( childEffective: EffectiveFilter; childFields: string[] | null; childStream: string; - consentTimeField: string | null; cursorField: string | null; foreignKeyField: string; primaryKeyField: string; @@ -1337,7 +1351,7 @@ function resolvePostgresExpansionFields( throw err; } const foreignKeyField = expansion.relationship.foreign_key; - assertSafeJsonField(foreignKeyField, "foreign_key"); + assertNonEmptyJsonField(foreignKeyField, "foreign_key"); if (typeof foreignKeyField !== "string") { throw invalidQueryError("Expand relation foreign_key must be a string", "invalid_expand"); } @@ -1367,7 +1381,7 @@ function resolvePostgresExpansionFields( if (!primaryKeyField) { throw invalidQueryError("Expand relation primary_key is empty", "invalid_expand"); } - assertSafeJsonField(primaryKeyField, "primary_key"); + assertNonEmptyJsonField(primaryKeyField, "primary_key"); const childRequiredFields = Array.isArray(childManifestStream.schema?.required) ? childManifestStream.schema.required : []; @@ -1380,7 +1394,6 @@ function resolvePostgresExpansionFields( childEffective, childFields: childEffective.fields ?? null, childStream, - consentTimeField: childManifestStream.consent_time_field || null, cursorField: childManifestStream.cursor_field || null, foreignKeyField, primaryKeyField, @@ -1438,7 +1451,8 @@ async function hydratePostgresExpansion({ return; } - const { childEffective, childFields, childStream, consentTimeField, cursorField, foreignKeyField, primaryKeyField } = + assertExpansionInstanceAuthorized(expansion.childGrant, connectorInstanceId); + const { childEffective, childFields, childStream, cursorField, foreignKeyField, primaryKeyField } = resolvePostgresExpansionFields(expansion, manifest); const fkExpr = jsonStringExpr(foreignKeyField); @@ -1456,17 +1470,16 @@ async function hydratePostgresExpansion({ const params: unknown[] = [connectorInstanceId, childStream]; const whereParts = ["connector_instance_id = $1", "stream = $2", "deleted = FALSE"]; - if (childEffective.timeRange && consentTimeField) { - assertSafeJsonField(consentTimeField, "consent_time_field"); - const ctExpr = jsonStringExpr(consentTimeField); - whereParts.push(`${ctExpr} IS NOT NULL`); - if (childEffective.timeRange.since !== null && childEffective.timeRange.since !== undefined) { - params.push(new Date(childEffective.timeRange.since).toISOString()); - whereParts.push(`${ctExpr} >= $${params.length}`); + if (childEffective.timeConstraint) { + const grantTimeExpr = postgresGrantTimeExpr(childEffective.timeConstraint.field); + whereParts.push(`${grantTimeExpr} IS NOT NULL`); + if (childEffective.timeConstraint.since !== null && childEffective.timeConstraint.since !== undefined) { + params.push(new Date(childEffective.timeConstraint.since).toISOString()); + whereParts.push(`${grantTimeExpr} >= $${params.length}::timestamptz`); } - if (childEffective.timeRange.until !== null && childEffective.timeRange.until !== undefined) { - params.push(new Date(childEffective.timeRange.until).toISOString()); - whereParts.push(`${ctExpr} < $${params.length}`); + if (childEffective.timeConstraint.until !== null && childEffective.timeConstraint.until !== undefined) { + params.push(new Date(childEffective.timeConstraint.until).toISOString()); + whereParts.push(`${grantTimeExpr} < $${params.length}::timestamptz`); } } @@ -2004,7 +2017,6 @@ async function visiblePostgresChange({ stream, decodedVersion, effective, - consentTimeField, compiledFilters, identity, }: { @@ -2013,14 +2025,13 @@ async function visiblePostgresChange({ stream: string; decodedVersion: number; effective: EffectiveFilter; - consentTimeField: string | null; compiledFilters: Parameters<typeof passesRequestFilters>[1]; identity: RecordIdentity | null; }): Promise<ResponseRecord | null> { const previous = await getPostgresSnapshotAtVersion(connectorInstanceId, stream, row.record_key, decodedVersion); const current = await getPostgresSnapshotAtVersion(connectorInstanceId, stream, row.record_key, Number(row.version)); - const previousVisible = isVisiblePostgresSnapshot(previous, effective, consentTimeField); - const currentVisible = isVisiblePostgresSnapshot(current, effective, consentTimeField); + const previousVisible = isVisiblePostgresSnapshot(previous, effective); + const currentVisible = isVisiblePostgresSnapshot(current, effective); if (row.deleted) { return previous && previousVisible && passesRequestFilters(previous.data, compiledFilters) ? deletedResponseRecord({ identity, row, stream }) @@ -2048,7 +2059,6 @@ async function queryPostgresChangesSince({ connectorInstanceId, effective, identity, - manifestStream, requestParams, requestWarnings, stream, @@ -2057,7 +2067,6 @@ async function queryPostgresChangesSince({ connectorInstanceId: string; effective: EffectiveFilter; identity: RecordIdentity | null; - manifestStream: ManifestStream | null; requestParams: QueryRequestParams; requestWarnings: unknown[]; stream: string; @@ -2101,14 +2110,12 @@ async function queryPostgresChangesSince({ [connectorInstanceId, stream, decodedVersion, sessionMax] ); const sorted = [...rows.rows].sort((a, b) => Number(a.version) - Number(b.version)); - const consentTimeField = manifestStream?.consent_time_field || null; const visibleChanges: ResponseRecord[] = []; await sorted.reduce(async (previous, row) => { await previous; const visible = await visiblePostgresChange({ compiledFilters, connectorInstanceId, - consentTimeField, decodedVersion, effective, identity, @@ -2317,7 +2324,6 @@ export async function postgresQueryRecords( connectorInstanceId, effective, identity, - manifestStream, requestParams, requestWarnings, stream, @@ -2326,7 +2332,7 @@ export async function postgresQueryRecords( const params: unknown[] = [connectorInstanceId, stream]; const whereParts = ["connector_instance_id = $1", "stream = $2", "deleted = FALSE"]; - appendGrantVisibilityClauses(whereParts, params, effective, manifestStream); + appendGrantVisibilityClauses(whereParts, params, effective); let where = `WHERE ${whereParts.join(" AND ")}`; where += buildFilterClause(compiledFilters, requestParams.filter, params); // Snapshot the filter-only WHERE clause / params for the graded-count @@ -2385,8 +2391,7 @@ async function computePostgresRecordWindow({ const window: JsonObject = { total }; if (consentTimeField) { - assertSafeJsonField(consentTimeField, "consent_time_field"); - const ctExpr = jsonStringExpr(consentTimeField); + const ctExpr = postgresTopLevelJsonExpr(consentTimeField); // MIN/MAX must compare CHRONOLOGICALLY, not lexicographically. Plain text // MIN/MAX picks the wrong bound for non-UTC offsets (e.g. a "...T00:00-07:00" // string sorts before "...T06:00+00:00" textually but is later in time), and @@ -2461,7 +2466,7 @@ async function readProjectedRecordCount({ if (hasRequestFilters(requestParams)) { return null; } - if (effective.timeRange) { + if (effective.timeConstraint) { return null; } if (Array.isArray(effective.resources) && effective.resources.length > 0) { @@ -2587,9 +2592,9 @@ export async function postgresGetRecord( err.code = "not_found"; throw err; } - if (effective.timeRange && manifestStream?.consent_time_field) { + if (effective.timeConstraint) { const rawData = typeof row.record_json === "string" ? JSON.parse(row.record_json) : row.record_json; - if (!passesTimeRange(rawData, effective.timeRange, manifestStream.consent_time_field)) { + if (!passesTimeConstraint(rawData, effective.timeConstraint)) { const err: PgQueryError = new Error("Record not found"); err.code = "not_found"; throw err; @@ -2625,14 +2630,14 @@ function assertPostgresFieldWindowAuthority(manifest: ConnectorManifest | null, async function fetchPostgresFieldWindowRow({ connectorInstanceId, - consentTimeField, + timeConstraintField, fieldPath, recordId, selector, stream, }: { connectorInstanceId: string; - consentTimeField: string | null; + timeConstraintField: string | null; fieldPath: string; recordId: string; selector: WindowSelector; @@ -2643,12 +2648,13 @@ async function fetchPostgresFieldWindowRow({ `WITH selected AS ( SELECT record_key, jsonb_typeof(record_json -> $4::text) AS field_type, record_json ->> $4::text AS field_text, + CASE WHEN $6::text IS NULL THEN NULL ELSE jsonb_typeof(record_json -> $6::text) END AS consent_time_type, CASE WHEN $6::text IS NULL THEN NULL ELSE record_json ->> $6::text END AS consent_time_value FROM records WHERE connector_instance_id = $1 AND stream = $2 AND record_key = $3 AND deleted = FALSE LIMIT 1 ), positioned AS ( - SELECT record_key, field_type, field_text, + SELECT record_key, field_type, field_text, consent_time_type, CASE WHEN field_type = 'string' THEN char_length(field_text) ELSE NULL END AS total_chars, CASE WHEN $5::text IS NOT NULL AND field_type = 'string' THEN strpos(lower(field_text), lower($5::text)) ELSE NULL END AS match_pos, @@ -2661,7 +2667,7 @@ async function fetchPostgresFieldWindowRow({ WHEN $5::text IS NOT NULL THEN greatest(1, match_pos - $7::integer) ELSE $8::integer END FOR $9::integer) ELSE NULL END AS window_text, - match_pos, consent_time_value + match_pos, consent_time_type, consent_time_value FROM positioned`, [ connectorInstanceId, @@ -2669,7 +2675,7 @@ async function fetchPostgresFieldWindowRow({ recordId, fieldPath, query, - consentTimeField, + timeConstraintField, selector.mode === "query" ? (selector.before ?? 0) : 0, selector.mode === "query" ? 1 : selector.offset + 1, selector.limit, @@ -2678,17 +2684,16 @@ async function fetchPostgresFieldWindowRow({ return result.rows[0]; } -function assertPostgresFieldWindowRowVisible( - row: PgRow, - effective: EffectiveFilter, - consentTimeField: string | null -): void { +function assertPostgresFieldWindowRowVisible(row: PgRow, effective: EffectiveFilter): void { if (effective.resources && !effective.resources.includes(row.record_key)) { throw fieldWindowError("not_found", "Record not found", 404); } - if (effective.timeRange && consentTimeField) { - const consentData = { [consentTimeField]: row.consent_time_value }; - if (!passesTimeRange(consentData, effective.timeRange, consentTimeField)) { + if (effective.timeConstraint) { + if (row.consent_time_type !== "string") { + throw fieldWindowError("not_found", "Record not found", 404); + } + const constraintData = { [effective.timeConstraint.field]: row.consent_time_value }; + if (!passesTimeConstraint(constraintData, effective.timeConstraint)) { throw fieldWindowError("not_found", "Record not found", 404); } } @@ -2724,20 +2729,20 @@ export async function postgresGetRecordFieldWindow( const { warnings: requestWarnings } = resolveRequestConnectionId(requestParams); enforceConnectionNarrowing(requestParams, connectorInstanceId); - const consentTimeField = manifestStream?.consent_time_field || null; + const timeConstraintField = effective.timeConstraintField ?? null; const row = await fetchPostgresFieldWindowRow({ connectorInstanceId, - consentTimeField, fieldPath, recordId, selector, stream, + timeConstraintField, }); if (!row) { throw fieldWindowError("not_found", "Record not found", 404); } - assertPostgresFieldWindowRowVisible(row, effective, consentTimeField); + assertPostgresFieldWindowRowVisible(row, effective); const fieldClass = classifyFieldType(row.field_type); assertReadableStringField(fieldPath, fieldClass); @@ -3097,7 +3102,7 @@ async function postgresRecordTimeBoundsForManifestRow( await previous; const field = stream?.consent_time_field; const streamName = stream?.name; - if (typeof field !== "string" || !field || typeof streamName !== "string" || !RECORD_TIME_FIELD.test(field)) { + if (typeof field !== "string" || !field || typeof streamName !== "string") { return; } const result = await postgresQuery( diff --git a/reference-implementation/server/postgres-storage.ts b/reference-implementation/server/postgres-storage.ts index c370b71ed..53525ee90 100644 --- a/reference-implementation/server/postgres-storage.ts +++ b/reference-implementation/server/postgres-storage.ts @@ -1285,6 +1285,9 @@ export async function bootstrapPostgresSchema({ CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( refresh_token_hash TEXT PRIMARY KEY, + family_id TEXT NOT NULL, + generation INTEGER NOT NULL, + parent_generation INTEGER, client_id TEXT NOT NULL, grant_id TEXT NOT NULL, subject_id TEXT NOT NULL, @@ -1292,13 +1295,13 @@ export async function bootstrapPostgresSchema({ created_at TEXT NOT NULL, expires_at TEXT, last_used_at TEXT, + superseded_at TEXT, revoked_at TEXT ); CREATE INDEX IF NOT EXISTS idx_pg_oauth_refresh_tokens_grant ON oauth_refresh_tokens(grant_id, status); CREATE INDEX IF NOT EXISTS idx_pg_oauth_refresh_tokens_client_status ON oauth_refresh_tokens(client_id, status, expires_at); - CREATE TABLE IF NOT EXISTS grants ( grant_id TEXT PRIMARY KEY, subject_id TEXT NOT NULL, @@ -1320,6 +1323,7 @@ export async function bootstrapPostgresSchema({ token_id TEXT PRIMARY KEY, grant_id TEXT, package_id TEXT, + refresh_family_id TEXT, subject_id TEXT NOT NULL, client_id TEXT, token_kind TEXT NOT NULL, @@ -1331,6 +1335,18 @@ export async function bootstrapPostgresSchema({ ON tokens(grant_id); CREATE INDEX IF NOT EXISTS idx_pg_tokens_client_id ON tokens(client_id); + CREATE TABLE IF NOT EXISTS consent_exchange_codes ( + code_hash TEXT PRIMARY KEY, + proof_hash TEXT, + token_id TEXT NOT NULL REFERENCES tokens(token_id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + redeemed_at TEXT + ); + ALTER TABLE consent_exchange_codes + ADD COLUMN IF NOT EXISTS proof_hash TEXT; + CREATE INDEX IF NOT EXISTS idx_pg_consent_exchange_codes_expiry + ON consent_exchange_codes(expires_at); CREATE TABLE IF NOT EXISTS grant_packages ( package_id TEXT PRIMARY KEY, @@ -1413,6 +1429,9 @@ export async function bootstrapPostgresSchema({ denied_at TEXT, interval_seconds INTEGER NOT NULL DEFAULT 2, last_polled_at TEXT, + approval_review_revision TEXT, + approval_review_digest TEXT, + approval_review_json JSONB, approval_id TEXT UNIQUE ); CREATE INDEX IF NOT EXISTS idx_pg_pending_consents_status_expires @@ -1421,19 +1440,94 @@ export async function bootstrapPostgresSchema({ ADD COLUMN IF NOT EXISTS interval_seconds INTEGER NOT NULL DEFAULT 2; ALTER TABLE pending_consents ADD COLUMN IF NOT EXISTS last_polled_at TEXT; + ALTER TABLE pending_consents + ADD COLUMN IF NOT EXISTS approval_review_revision TEXT; + ALTER TABLE pending_consents + ADD COLUMN IF NOT EXISTS approval_review_digest TEXT; + ALTER TABLE pending_consents + ADD COLUMN IF NOT EXISTS approval_review_json JSONB; + + CREATE TABLE IF NOT EXISTS agent_connect_attempts ( + id TEXT PRIMARY KEY, + request_uri TEXT NOT NULL, + client_id TEXT, + polling_code_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + approval_url TEXT NOT NULL, + token_url TEXT NOT NULL, + interval_seconds INTEGER NOT NULL DEFAULT 2, + created_at TEXT NOT NULL, + expires_at_ms BIGINT NOT NULL, + completed_at TEXT, + grant_id TEXT, + grant_json JSONB, + token TEXT, + response_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_pg_agent_connect_attempts_request_uri + ON agent_connect_attempts(request_uri, status); + CREATE INDEX IF NOT EXISTS idx_pg_agent_connect_attempts_status_expires + ON agent_connect_attempts(status, expires_at_ms); ALTER TABLE tokens ADD COLUMN IF NOT EXISTS package_id TEXT; + ALTER TABLE tokens + ADD COLUMN IF NOT EXISTS refresh_family_id TEXT; ALTER TABLE oauth_authorization_codes ADD COLUMN IF NOT EXISTS package_id TEXT; ALTER TABLE oauth_refresh_tokens ADD COLUMN IF NOT EXISTS package_id TEXT; + ALTER TABLE oauth_refresh_tokens + ADD COLUMN IF NOT EXISTS family_id TEXT; + ALTER TABLE oauth_refresh_tokens + ADD COLUMN IF NOT EXISTS generation INTEGER; + ALTER TABLE oauth_refresh_tokens + ADD COLUMN IF NOT EXISTS parent_generation INTEGER; + ALTER TABLE oauth_refresh_tokens + ADD COLUMN IF NOT EXISTS superseded_at TEXT; ALTER TABLE oauth_refresh_tokens ALTER COLUMN grant_id DROP NOT NULL; CREATE INDEX IF NOT EXISTS idx_pg_tokens_package_id ON tokens(package_id); + CREATE INDEX IF NOT EXISTS idx_pg_tokens_refresh_family + ON tokens(refresh_family_id, revoked); + UPDATE tokens AS bearer + SET revoked = TRUE + WHERE bearer.revoked = FALSE + AND ( + bearer.grant_id IN ( + SELECT legacy.grant_id + FROM oauth_refresh_tokens AS legacy + WHERE legacy.grant_id IS NOT NULL + AND legacy.status <> 'revoked' + AND NOT EXISTS ( + SELECT 1 FROM tokens AS linked WHERE linked.refresh_family_id = legacy.family_id + ) + ) + OR bearer.package_id IN ( + SELECT legacy.package_id + FROM oauth_refresh_tokens AS legacy + WHERE legacy.package_id IS NOT NULL + AND legacy.status <> 'revoked' + AND NOT EXISTS ( + SELECT 1 FROM tokens AS linked WHERE linked.refresh_family_id = legacy.family_id + ) + ) + ); + UPDATE oauth_refresh_tokens AS legacy + SET status = 'revoked', + revoked_at = COALESCE( + legacy.revoked_at, + TO_CHAR(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') + ) + WHERE legacy.status <> 'revoked' + AND NOT EXISTS ( + SELECT 1 FROM tokens AS linked WHERE linked.refresh_family_id = legacy.family_id + ); CREATE INDEX IF NOT EXISTS idx_pg_oauth_refresh_tokens_package ON oauth_refresh_tokens(package_id, status); + CREATE UNIQUE INDEX IF NOT EXISTS idx_pg_oauth_refresh_tokens_family_generation + ON oauth_refresh_tokens(family_id, generation); CREATE INDEX IF NOT EXISTS idx_pg_oauth_authorization_codes_package ON oauth_authorization_codes(package_id, status); diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/count-by-status.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/count-by-status.sql new file mode 100644 index 000000000..2b2cf9e07 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/count-by-status.sql @@ -0,0 +1,4 @@ +-- @terminator: one +SELECT COUNT(*) AS count +FROM agent_connect_attempts +WHERE status = ? diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/delete-by-id.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/delete-by-id.sql new file mode 100644 index 000000000..80b9dbe77 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/delete-by-id.sql @@ -0,0 +1,2 @@ +-- @terminator: exec +DELETE FROM agent_connect_attempts WHERE id = ? diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-by-id.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-by-id.sql new file mode 100644 index 000000000..d0ab99598 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-by-id.sql @@ -0,0 +1,4 @@ +-- @terminator: exec +DELETE FROM agent_connect_attempts +WHERE id = ? + AND status = 'expired' diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-historic-page.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-historic-page.sql new file mode 100644 index 000000000..a17fdc7d3 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-historic-page.sql @@ -0,0 +1,12 @@ +-- @terminator: exec +DELETE FROM agent_connect_attempts +WHERE rowid IN ( + SELECT rowid + FROM agent_connect_attempts + WHERE status = 'expired' + AND expires_at_ms <= ? + AND request_uri NOT LIKE 'urn:pdpp:pending-consent:%' + AND token IS NULL + ORDER BY rowid + LIMIT ? +) diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-if-consent-terminal.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-if-consent-terminal.sql new file mode 100644 index 000000000..0cfd11d00 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/delete-expired-if-consent-terminal.sql @@ -0,0 +1,10 @@ +-- @terminator: exec +DELETE FROM agent_connect_attempts +WHERE id = ? + AND status = 'expired' + AND NOT EXISTS ( + SELECT 1 + FROM pending_consents + WHERE device_code = ? + AND status IN ('pending', 'approving', 'approved') + ) diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/get-by-id.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/get-by-id.sql new file mode 100644 index 000000000..1a8c46449 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/get-by-id.sql @@ -0,0 +1,5 @@ +-- @terminator: one +SELECT * +FROM agent_connect_attempts +WHERE id = ? +LIMIT 1 diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/get-expired-by-request-uri.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/get-expired-by-request-uri.sql new file mode 100644 index 000000000..0e089c718 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/get-expired-by-request-uri.sql @@ -0,0 +1,6 @@ +-- @terminator: one +SELECT id +FROM agent_connect_attempts +WHERE request_uri = ? + AND status = 'expired' +LIMIT 1 diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/insert-if-consent-pending.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/insert-if-consent-pending.sql new file mode 100644 index 000000000..cdae1ab19 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/insert-if-consent-pending.sql @@ -0,0 +1,13 @@ +-- @terminator: exec +INSERT INTO agent_connect_attempts( + id, request_uri, client_id, polling_code_hash, status, approval_url, token_url, + interval_seconds, created_at, expires_at_ms +) +SELECT ?, ?, ?, ?, 'pending', ?, ?, 2, ?, ? +WHERE EXISTS ( + SELECT 1 + FROM pending_consents + WHERE device_code = ? + AND status = 'pending' + AND expires_at > ? +) diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/insert.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/insert.sql new file mode 100644 index 000000000..c6740784b --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/insert.sql @@ -0,0 +1,5 @@ +-- @terminator: exec +INSERT INTO agent_connect_attempts( + id, request_uri, client_id, polling_code_hash, status, approval_url, token_url, + interval_seconds, created_at, expires_at_ms +) VALUES(?, ?, ?, ?, 'pending', ?, ?, 2, ?, ?) diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/list-expired-pending.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/list-expired-pending.sql new file mode 100644 index 000000000..c2201f57f --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/list-expired-pending.sql @@ -0,0 +1,9 @@ +-- @terminator: many +-- @cursor_field: rowid +SELECT rowid, + * +FROM agent_connect_attempts +WHERE status = 'pending' + AND expires_at_ms <= ? +ORDER BY rowid +LIMIT ? diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/list-expired-tombstones.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/list-expired-tombstones.sql new file mode 100644 index 000000000..6cac7f13e --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/list-expired-tombstones.sql @@ -0,0 +1,17 @@ +-- @terminator: many +-- @cursor_field: rowid +SELECT attempts.rowid, + attempts.* +FROM agent_connect_attempts AS attempts +LEFT JOIN pending_consents AS consent + ON attempts.request_uri = 'urn:pdpp:pending-consent:' || consent.device_code +WHERE attempts.status = 'expired' + AND attempts.expires_at_ms <= ? + AND attempts.request_uri LIKE 'urn:pdpp:pending-consent:%' + AND ( + consent.device_code IS NULL + OR consent.status IN ('denied', 'expired') + OR (consent.status = 'pending' AND consent.expires_at <= ?) + ) +ORDER BY attempts.rowid +LIMIT ? diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/mark-approved.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/mark-approved.sql new file mode 100644 index 000000000..1a50f291a --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/mark-approved.sql @@ -0,0 +1,9 @@ +-- @terminator: exec +UPDATE agent_connect_attempts + SET status = 'approved', + completed_at = ?, + token = ?, + grant_json = ?, + grant_id = ? + WHERE request_uri = ? + AND status = 'pending' diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/mark-expired-by-id.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/mark-expired-by-id.sql new file mode 100644 index 000000000..ab1cc8f9c --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/mark-expired-by-id.sql @@ -0,0 +1,6 @@ +-- @terminator: exec +UPDATE agent_connect_attempts + SET status = 'expired', + completed_at = ? + WHERE id = ? + AND status IN ('pending', 'expired') diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/mark-failed.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/mark-failed.sql new file mode 100644 index 000000000..bee9bbf57 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/mark-failed.sql @@ -0,0 +1,6 @@ +-- @terminator: exec +UPDATE agent_connect_attempts + SET status = ?, + completed_at = ? + WHERE request_uri = ? + AND status = 'pending' diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/prune.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/prune.sql new file mode 100644 index 000000000..9e29ef02f --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/prune.sql @@ -0,0 +1,4 @@ +-- @terminator: exec +DELETE FROM agent_connect_attempts + WHERE status = 'denied' + OR (status = 'approved' AND response_json IS NOT NULL AND expires_at_ms <= ?) diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/recover-approved.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/recover-approved.sql new file mode 100644 index 000000000..c8498ed39 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/recover-approved.sql @@ -0,0 +1,13 @@ +-- @terminator: one +SELECT pc.grant_id, + pc.token_id, + g.grant_json, + gp.package_json + FROM pending_consents pc + LEFT JOIN grants g ON g.grant_id = pc.grant_id + LEFT JOIN grant_packages gp ON gp.package_id = pc.grant_id + WHERE pc.device_code = ? + AND pc.status = 'approved' + AND pc.token_id IS NOT NULL + AND pc.grant_id IS NOT NULL + LIMIT 1 diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/revoke-token-if-no-live-sibling.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/revoke-token-if-no-live-sibling.sql new file mode 100644 index 000000000..7a36ca009 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/revoke-token-if-no-live-sibling.sql @@ -0,0 +1,13 @@ +-- @terminator: exec +UPDATE tokens +SET revoked = 1 +WHERE token_id = ? + AND revoked = 0 + AND NOT EXISTS ( + SELECT 1 + FROM agent_connect_attempts + WHERE request_uri = ? + AND id != ? + AND status IN ('pending', 'approved') + AND expires_at_ms > ? + ) diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/revoke-token.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/revoke-token.sql new file mode 100644 index 000000000..b98105a90 --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/revoke-token.sql @@ -0,0 +1,5 @@ +-- @terminator: exec +UPDATE tokens + SET revoked = 1 + WHERE token_id = ? + AND revoked = 0 diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/set-expires-at-by-id.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/set-expires-at-by-id.sql new file mode 100644 index 000000000..ae9f8978b --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/set-expires-at-by-id.sql @@ -0,0 +1,4 @@ +-- @terminator: exec +UPDATE agent_connect_attempts + SET expires_at_ms = ? + WHERE id = ? diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/set-response-json.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/set-response-json.sql new file mode 100644 index 000000000..75ca91a8b --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/set-response-json.sql @@ -0,0 +1,6 @@ +-- @terminator: exec +UPDATE agent_connect_attempts + SET response_json = ? + WHERE id = ? + AND status = 'approved' + AND response_json IS NULL diff --git a/reference-implementation/server/queries/auth/agent-connect-attempts/token-active.sql b/reference-implementation/server/queries/auth/agent-connect-attempts/token-active.sql new file mode 100644 index 000000000..b924e004b --- /dev/null +++ b/reference-implementation/server/queries/auth/agent-connect-attempts/token-active.sql @@ -0,0 +1,7 @@ +-- @terminator: one +SELECT 1 AS ok +FROM tokens +WHERE token_id = ? + AND revoked = 0 + AND (expires_at IS NULL OR expires_at > ?) +LIMIT 1 diff --git a/reference-implementation/server/queries/auth/connector-instances/get-reviewed-active.sql b/reference-implementation/server/queries/auth/connector-instances/get-reviewed-active.sql new file mode 100644 index 000000000..666b76752 --- /dev/null +++ b/reference-implementation/server/queries/auth/connector-instances/get-reviewed-active.sql @@ -0,0 +1,8 @@ +-- @terminator: one +SELECT connector_instance_id +FROM connector_instances +WHERE connector_instance_id = ? + AND connector_id = ? + AND owner_subject_id = ? + AND status = 'active' +LIMIT 1; diff --git a/reference-implementation/server/queries/auth/consent-exchange-codes/get-for-redemption.sql b/reference-implementation/server/queries/auth/consent-exchange-codes/get-for-redemption.sql new file mode 100644 index 000000000..bb0983d95 --- /dev/null +++ b/reference-implementation/server/queries/auth/consent-exchange-codes/get-for-redemption.sql @@ -0,0 +1,7 @@ +-- @terminator: one +SELECT c.code_hash, c.proof_hash, c.token_id, c.created_at, c.expires_at, c.redeemed_at, + t.grant_id, t.package_id, t.revoked AS token_revoked, + t.expires_at AS token_expires_at +FROM consent_exchange_codes c +JOIN tokens t ON t.token_id = c.token_id +WHERE c.code_hash = ? diff --git a/reference-implementation/server/queries/auth/consent-exchange-codes/insert.sql b/reference-implementation/server/queries/auth/consent-exchange-codes/insert.sql new file mode 100644 index 000000000..6794bd893 --- /dev/null +++ b/reference-implementation/server/queries/auth/consent-exchange-codes/insert.sql @@ -0,0 +1,4 @@ +-- @terminator: exec +INSERT INTO consent_exchange_codes( + code_hash, proof_hash, token_id, created_at, expires_at, redeemed_at +) VALUES(?, ?, ?, ?, ?, NULL) diff --git a/reference-implementation/server/queries/auth/consent-exchange-codes/invalidate-outstanding-by-token.sql b/reference-implementation/server/queries/auth/consent-exchange-codes/invalidate-outstanding-by-token.sql new file mode 100644 index 000000000..91fcd7fc1 --- /dev/null +++ b/reference-implementation/server/queries/auth/consent-exchange-codes/invalidate-outstanding-by-token.sql @@ -0,0 +1,4 @@ +-- @terminator: exec +UPDATE consent_exchange_codes +SET redeemed_at = ?, expires_at = ? +WHERE token_id = ? AND redeemed_at IS NULL diff --git a/reference-implementation/server/queries/auth/consent-exchange-codes/mark-redeemed.sql b/reference-implementation/server/queries/auth/consent-exchange-codes/mark-redeemed.sql new file mode 100644 index 000000000..a23d98583 --- /dev/null +++ b/reference-implementation/server/queries/auth/consent-exchange-codes/mark-redeemed.sql @@ -0,0 +1,4 @@ +-- @terminator: exec +UPDATE consent_exchange_codes +SET redeemed_at = ? +WHERE code_hash = ? AND redeemed_at IS NULL diff --git a/reference-implementation/server/queries/auth/grant-package-members/list-active-by-package.sql b/reference-implementation/server/queries/auth/grant-package-members/list-active-by-package.sql index e60040392..4087931e3 100644 --- a/reference-implementation/server/queries/auth/grant-package-members/list-active-by-package.sql +++ b/reference-implementation/server/queries/auth/grant-package-members/list-active-by-package.sql @@ -4,7 +4,12 @@ -- @max_rows: 256 SELECT gm.package_id, gm.grant_id, gm.token_id, gm.source_json, gm.status, gm.added_at, gm.revoked_at, g.status AS grant_status, g.grant_json, g.storage_binding_json, - t.revoked AS token_revoked, t.expires_at AS token_expires_at + g.grant_id AS persisted_grant_id, g.subject_id AS grant_subject_id, + g.client_id AS grant_client_id, g.access_mode AS grant_access_mode, + g.expires_at AS grant_expires_at, + t.grant_id AS token_grant_id, t.subject_id AS token_subject_id, + t.client_id AS token_client_id, t.revoked AS token_revoked, + t.expires_at AS token_expires_at FROM grant_package_members gm JOIN grants g ON gm.grant_id = g.grant_id JOIN tokens t ON gm.token_id = t.token_id diff --git a/reference-implementation/server/queries/auth/grant-package-members/list-all-by-package.sql b/reference-implementation/server/queries/auth/grant-package-members/list-all-by-package.sql index 10d23b582..cc9f75317 100644 --- a/reference-implementation/server/queries/auth/grant-package-members/list-all-by-package.sql +++ b/reference-implementation/server/queries/auth/grant-package-members/list-all-by-package.sql @@ -8,7 +8,8 @@ SELECT gm.package_id, gm.status AS member_status, gm.added_at, gm.revoked_at AS member_revoked_at, - g.status AS grant_status + g.status AS grant_status, + g.access_mode AS grant_access_mode FROM grant_package_members gm JOIN grants g ON gm.grant_id = g.grant_id WHERE gm.package_id = ? diff --git a/reference-implementation/server/queries/auth/grant-packages/list-all.sql b/reference-implementation/server/queries/auth/grant-packages/list-all.sql index 9cc07ed74..2e3a65bdd 100644 --- a/reference-implementation/server/queries/auth/grant-packages/list-all.sql +++ b/reference-implementation/server/queries/auth/grant-packages/list-all.sql @@ -7,6 +7,7 @@ SELECT gp.subject_id, gp.client_id, gp.status, + gp.package_json, gp.parent_package_id, gp.trace_id, gp.scenario_id, diff --git a/reference-implementation/server/queries/auth/grants/get-for-issuance.sql b/reference-implementation/server/queries/auth/grants/get-for-issuance.sql index a5862899d..b694681bd 100644 --- a/reference-implementation/server/queries/auth/grants/get-for-issuance.sql +++ b/reference-implementation/server/queries/auth/grants/get-for-issuance.sql @@ -1,4 +1,8 @@ -- @terminator: one -SELECT access_mode, consumed, status, trace_id, scenario_id, grant_json, storage_binding_json +SELECT grant_id AS persisted_grant_id, subject_id AS grant_subject_id, + client_id AS grant_client_id, access_mode AS grant_access_mode, + expires_at AS grant_expires_at, + grant_id, subject_id, client_id, access_mode, expires_at, + consumed, status, trace_id, scenario_id, grant_json, storage_binding_json FROM grants WHERE grant_id = ? diff --git a/reference-implementation/server/queries/auth/grants/get-for-revocation.sql b/reference-implementation/server/queries/auth/grants/get-for-revocation.sql index f03d7e225..c1bce3af6 100644 --- a/reference-implementation/server/queries/auth/grants/get-for-revocation.sql +++ b/reference-implementation/server/queries/auth/grants/get-for-revocation.sql @@ -1,4 +1,8 @@ -- @terminator: one -SELECT client_id, subject_id, trace_id, scenario_id, grant_json, storage_binding_json +SELECT grant_id AS persisted_grant_id, subject_id AS grant_subject_id, + client_id AS grant_client_id, access_mode AS grant_access_mode, + expires_at AS grant_expires_at, + grant_id, client_id, subject_id, access_mode, expires_at, + status, trace_id, scenario_id, grant_json, storage_binding_json FROM grants WHERE grant_id = ? diff --git a/reference-implementation/server/queries/auth/oauth-authorization-codes/get-by-device-code.sql b/reference-implementation/server/queries/auth/oauth-authorization-codes/get-by-device-code.sql index 8b0008449..80abd084c 100644 --- a/reference-implementation/server/queries/auth/oauth-authorization-codes/get-by-device-code.sql +++ b/reference-implementation/server/queries/auth/oauth-authorization-codes/get-by-device-code.sql @@ -1,4 +1,5 @@ -- @terminator: one -SELECT id, device_code, client_id, redirect_uri, state, status, expires_at +SELECT id, device_code, client_id, redirect_uri, state, status, expires_at, + code AS issued_code, grant_id, package_id, token_id, issued_at, consumed_at FROM oauth_authorization_codes WHERE device_code = ? diff --git a/reference-implementation/server/queries/auth/oauth-refresh-tokens/get-by-token.sql b/reference-implementation/server/queries/auth/oauth-refresh-tokens/get-by-token.sql index 3436b708c..646545d98 100644 --- a/reference-implementation/server/queries/auth/oauth-refresh-tokens/get-by-token.sql +++ b/reference-implementation/server/queries/auth/oauth-refresh-tokens/get-by-token.sql @@ -1,5 +1,6 @@ -- @terminator: one -SELECT refresh_token_hash, client_id, grant_id, package_id, subject_id, status, created_at, - expires_at, last_used_at, revoked_at +SELECT refresh_token_hash, family_id, generation, parent_generation, client_id, grant_id, + package_id, subject_id, status, created_at, expires_at, last_used_at, + superseded_at, revoked_at FROM oauth_refresh_tokens WHERE refresh_token_hash = ? diff --git a/reference-implementation/server/queries/auth/oauth-refresh-tokens/insert-package.sql b/reference-implementation/server/queries/auth/oauth-refresh-tokens/insert-package.sql index 669404f5f..728b2588f 100644 --- a/reference-implementation/server/queries/auth/oauth-refresh-tokens/insert-package.sql +++ b/reference-implementation/server/queries/auth/oauth-refresh-tokens/insert-package.sql @@ -1,6 +1,7 @@ -- @terminator: exec INSERT INTO oauth_refresh_tokens( - refresh_token_hash, client_id, grant_id, package_id, subject_id, status, - created_at, expires_at, last_used_at, revoked_at + refresh_token_hash, family_id, generation, parent_generation, client_id, + grant_id, package_id, subject_id, status, created_at, expires_at, + last_used_at, superseded_at, revoked_at ) -VALUES(?, ?, NULL, ?, ?, 'active', ?, ?, NULL, NULL) +VALUES(?, ?, ?, ?, ?, NULL, ?, ?, 'active', ?, ?, NULL, NULL, NULL) diff --git a/reference-implementation/server/queries/auth/oauth-refresh-tokens/insert.sql b/reference-implementation/server/queries/auth/oauth-refresh-tokens/insert.sql index 591e24c12..a622509cf 100644 --- a/reference-implementation/server/queries/auth/oauth-refresh-tokens/insert.sql +++ b/reference-implementation/server/queries/auth/oauth-refresh-tokens/insert.sql @@ -1,6 +1,7 @@ -- @terminator: exec INSERT INTO oauth_refresh_tokens( - refresh_token_hash, client_id, grant_id, subject_id, status, - created_at, expires_at, last_used_at, revoked_at + refresh_token_hash, family_id, generation, parent_generation, client_id, + grant_id, subject_id, status, created_at, expires_at, last_used_at, + superseded_at, revoked_at ) -VALUES(?, ?, ?, ?, 'active', ?, ?, NULL, NULL) +VALUES(?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, NULL, NULL, NULL) diff --git a/reference-implementation/server/queries/auth/oauth-refresh-tokens/revoke-family.sql b/reference-implementation/server/queries/auth/oauth-refresh-tokens/revoke-family.sql new file mode 100644 index 000000000..663f2e373 --- /dev/null +++ b/reference-implementation/server/queries/auth/oauth-refresh-tokens/revoke-family.sql @@ -0,0 +1,5 @@ +-- @terminator: exec +UPDATE oauth_refresh_tokens +SET status = 'revoked', + revoked_at = ? +WHERE family_id = ? AND status <> 'revoked' diff --git a/reference-implementation/server/queries/auth/oauth-refresh-tokens/mark-used.sql b/reference-implementation/server/queries/auth/oauth-refresh-tokens/supersede-active.sql similarity index 58% rename from reference-implementation/server/queries/auth/oauth-refresh-tokens/mark-used.sql rename to reference-implementation/server/queries/auth/oauth-refresh-tokens/supersede-active.sql index 0c033875f..7c5f25a8b 100644 --- a/reference-implementation/server/queries/auth/oauth-refresh-tokens/mark-used.sql +++ b/reference-implementation/server/queries/auth/oauth-refresh-tokens/supersede-active.sql @@ -1,4 +1,6 @@ -- @terminator: exec UPDATE oauth_refresh_tokens -SET last_used_at = ? +SET status = 'superseded', + last_used_at = ?, + superseded_at = ? WHERE refresh_token_hash = ? AND status = 'active' diff --git a/reference-implementation/server/queries/auth/pending-consents/mark-expired-if-due.sql b/reference-implementation/server/queries/auth/pending-consents/mark-expired-if-due.sql new file mode 100644 index 000000000..7ab53737f --- /dev/null +++ b/reference-implementation/server/queries/auth/pending-consents/mark-expired-if-due.sql @@ -0,0 +1,6 @@ +-- @terminator: exec +UPDATE pending_consents +SET status = 'expired' +WHERE device_code = ? + AND status = 'pending' + AND expires_at <= ? diff --git a/reference-implementation/server/queries/auth/tokens/get-introspection.sql b/reference-implementation/server/queries/auth/tokens/get-introspection.sql index 8163b9e99..f2b6f58c7 100644 --- a/reference-implementation/server/queries/auth/tokens/get-introspection.sql +++ b/reference-implementation/server/queries/auth/tokens/get-introspection.sql @@ -1,7 +1,23 @@ -- @terminator: one -SELECT t.token_id, t.grant_id, t.package_id, t.subject_id, t.client_id, t.token_kind, t.expires_at, t.revoked, +SELECT t.token_id, t.grant_id, t.package_id, t.refresh_family_id, + CASE + WHEN t.refresh_family_id IS NULL THEN NULL + ELSE EXISTS( + SELECT 1 + FROM oauth_refresh_tokens rt + WHERE rt.family_id = t.refresh_family_id + AND rt.status = 'active' + AND rt.revoked_at IS NULL + ) + END AS refresh_family_active, + t.subject_id, t.client_id, t.token_kind, t.expires_at, t.revoked, g.status as grant_status, g.grant_json, g.trace_id, g.scenario_id, + g.grant_id AS persisted_grant_id, g.subject_id AS grant_subject_id, + g.client_id AS grant_client_id, g.access_mode AS grant_access_mode, + g.expires_at AS grant_expires_at, gp.status as package_status, gp.package_json, gp.trace_id as package_trace_id, gp.scenario_id as package_scenario_id, + gp.package_id AS persisted_package_id, gp.subject_id AS package_subject_id, + gp.client_id AS package_client_id, g.storage_binding_json FROM tokens t LEFT JOIN grants g ON t.grant_id = g.grant_id diff --git a/reference-implementation/server/queries/auth/tokens/insert-refresh-client.sql b/reference-implementation/server/queries/auth/tokens/insert-refresh-client.sql new file mode 100644 index 000000000..880db896d --- /dev/null +++ b/reference-implementation/server/queries/auth/tokens/insert-refresh-client.sql @@ -0,0 +1,5 @@ +-- @terminator: exec +INSERT INTO tokens( + token_id, grant_id, refresh_family_id, subject_id, client_id, token_kind, expires_at +) +VALUES(?, ?, ?, ?, ?, 'client', ?) diff --git a/reference-implementation/server/queries/auth/tokens/insert-refresh-mcp-package.sql b/reference-implementation/server/queries/auth/tokens/insert-refresh-mcp-package.sql new file mode 100644 index 000000000..c3e69a9fd --- /dev/null +++ b/reference-implementation/server/queries/auth/tokens/insert-refresh-mcp-package.sql @@ -0,0 +1,5 @@ +-- @terminator: exec +INSERT INTO tokens( + token_id, grant_id, package_id, refresh_family_id, subject_id, client_id, token_kind, expires_at +) +VALUES(?, NULL, ?, ?, ?, ?, 'mcp_package', ?) diff --git a/reference-implementation/server/queries/auth/tokens/link-refresh-family.sql b/reference-implementation/server/queries/auth/tokens/link-refresh-family.sql new file mode 100644 index 000000000..ed5d8b464 --- /dev/null +++ b/reference-implementation/server/queries/auth/tokens/link-refresh-family.sql @@ -0,0 +1,6 @@ +-- @terminator: exec +UPDATE tokens +SET refresh_family_id = ?, + expires_at = ? +WHERE token_id = ? + AND refresh_family_id IS NULL diff --git a/reference-implementation/server/queries/auth/tokens/revoke-by-refresh-family.sql b/reference-implementation/server/queries/auth/tokens/revoke-by-refresh-family.sql new file mode 100644 index 000000000..3c72008b0 --- /dev/null +++ b/reference-implementation/server/queries/auth/tokens/revoke-by-refresh-family.sql @@ -0,0 +1,5 @@ +-- @terminator: exec +UPDATE tokens +SET revoked = 1 +WHERE refresh_family_id = ? + AND revoked = 0 diff --git a/reference-implementation/server/queries/grants/get-scoped-state-by-id.sql b/reference-implementation/server/queries/grants/get-scoped-state-by-id.sql index 4c7453f8c..08fd2d9c9 100644 --- a/reference-implementation/server/queries/grants/get-scoped-state-by-id.sql +++ b/reference-implementation/server/queries/grants/get-scoped-state-by-id.sql @@ -3,6 +3,14 @@ -- grant-scoped state lookup. Returns enough context to (a) reconstruct -- the grant + storage_binding via requireResolvedPersistedGrantState -- and (b) attach trace/scenario IDs to error replies. -SELECT grant_json, storage_binding_json, trace_id, scenario_id +SELECT grant_id AS persisted_grant_id, + subject_id AS grant_subject_id, + client_id AS grant_client_id, + access_mode AS grant_access_mode, + expires_at AS grant_expires_at, + grant_json, + storage_binding_json, + trace_id, + scenario_id FROM grants WHERE grant_id = ? diff --git a/reference-implementation/server/queries/index.ts b/reference-implementation/server/queries/index.ts index 3b40e6213..aec820763 100644 --- a/reference-implementation/server/queries/index.ts +++ b/reference-implementation/server/queries/index.ts @@ -139,11 +139,36 @@ export interface ReferenceQueryRegistry extends Readonly<Record<string, Register // Approvals — `/_ref/approvals` projection. readonly approvalsListPendingConsents: SmallEnumerationQuery; readonly approvalsListPendingOwnerDevices: SmallEnumerationQuery; + readonly authAgentConnectAttemptsCountByStatus: ReadOneQuery; + readonly authAgentConnectAttemptsDeleteById: MutationQuery; + readonly authAgentConnectAttemptsDeleteExpiredById: MutationQuery; + readonly authAgentConnectAttemptsDeleteExpiredHistoricPage: MutationQuery; + readonly authAgentConnectAttemptsDeleteExpiredIfConsentTerminal: MutationQuery; + readonly authAgentConnectAttemptsGetById: ReadOneQuery; + readonly authAgentConnectAttemptsGetExpiredByRequestUri: ReadOneQuery; + readonly authAgentConnectAttemptsInsert: MutationQuery; + readonly authAgentConnectAttemptsInsertIfConsentPending: MutationQuery; + readonly authAgentConnectAttemptsListExpiredPending: ReadManyQuery; + readonly authAgentConnectAttemptsListExpiredTombstones: ReadManyQuery; + readonly authAgentConnectAttemptsMarkApproved: MutationQuery; + readonly authAgentConnectAttemptsMarkExpiredById: MutationQuery; + readonly authAgentConnectAttemptsMarkFailed: MutationQuery; + readonly authAgentConnectAttemptsPrune: MutationQuery; + readonly authAgentConnectAttemptsRecoverApproved: ReadOneQuery; + readonly authAgentConnectAttemptsRevokeToken: MutationQuery; + readonly authAgentConnectAttemptsRevokeTokenIfNoLiveSibling: MutationQuery; + readonly authAgentConnectAttemptsSetExpiresAtById: MutationQuery; + readonly authAgentConnectAttemptsSetResponseJson: MutationQuery; + readonly authAgentConnectAttemptsTokenActive: ReadOneQuery; + readonly authConnectorInstancesGetReviewedActive: ReadOneQuery; readonly authConnectorsGetManifestById: ReadOneQuery; // Auth — connectors (manifest registry) readonly authConnectorsInsertIfAbsent: MutationQuery; readonly authConnectorsListIds: SmallEnumerationQuery; readonly authConnectorsUpsert: MutationQuery; + readonly authConsentExchangeCodesGetForRedemption: ReadOneQuery; + readonly authConsentExchangeCodesInsert: MutationQuery; + readonly authConsentExchangeCodesMarkRedeemed: MutationQuery; readonly authGrantPackageMembersGetPackageIdByGrant: ReadOneQuery; readonly authGrantPackageMembersInsert: MutationQuery; readonly authGrantPackageMembersListActiveByPackage: SmallEnumerationQuery; @@ -177,9 +202,10 @@ export interface ReferenceQueryRegistry extends Readonly<Record<string, Register // Auth — oauth_refresh_tokens (hosted MCP durable OAuth sessions) readonly authOauthRefreshTokensGetByToken: ReadOneQuery; readonly authOauthRefreshTokensInsert: MutationQuery; - readonly authOauthRefreshTokensMarkUsed: MutationQuery; readonly authOauthRefreshTokensRevokeByGrant: MutationQuery; readonly authOauthRefreshTokensRevokeByPackage: MutationQuery; + readonly authOauthRefreshTokensRevokeFamily: MutationQuery; + readonly authOauthRefreshTokensSupersedeActive: MutationQuery; // Auth — owner_device_auth (owner CLI device-flow authentication) readonly authOwnerDeviceAuthGetByApprovalId: ReadOneQuery; readonly authOwnerDeviceAuthGetByDeviceCode: ReadOneQuery; @@ -196,16 +222,21 @@ export interface ReferenceQueryRegistry extends Readonly<Record<string, Register readonly authPendingConsentsMarkApproved: MutationQuery; readonly authPendingConsentsMarkDenied: MutationQuery; readonly authPendingConsentsMarkExpired: MutationQuery; + readonly authPendingConsentsMarkExpiredIfDue: MutationQuery; // Auth — tokens readonly authTokensCountActiveByClientId: ReadOneQuery; readonly authTokensGetIntrospection: ReadOneQuery; readonly authTokensInsertClient: MutationQuery; readonly authTokensInsertMcpPackage: MutationQuery; readonly authTokensInsertOwner: MutationQuery; + readonly authTokensInsertRefreshClient: MutationQuery; + readonly authTokensInsertRefreshMcpPackage: MutationQuery; + readonly authTokensLinkRefreshFamily: MutationQuery; readonly authTokensListActiveByClientId: SmallEnumerationQuery; readonly authTokensRevokeByClientId: MutationQuery; readonly authTokensRevokeByGrant: MutationQuery; readonly authTokensRevokeByPackage: MutationQuery; + readonly authTokensRevokeByRefreshFamily: MutationQuery; readonly authTokensRevokeByTokenId: MutationQuery; readonly blobsGetRowById: ReadOneQuery; readonly blobsGetStoredById: ReadOneQuery; @@ -738,9 +769,15 @@ export function loadReferenceQueries(queryDir = QUERIES_DIR): ReferenceQueryRegi "authOauthClientsDeleteByClientId", // Auth — connectors "authConnectorsInsertIfAbsent", + "authConnectorInstancesGetReviewedActive", "authConnectorsUpsert", "authConnectorsListIds", "authConnectorsGetManifestById", + // Auth — consent_exchange_codes + "authConsentExchangeCodesGetForRedemption", + "authConsentExchangeCodesInvalidateOutstandingByToken", + "authConsentExchangeCodesInsert", + "authConsentExchangeCodesMarkRedeemed", // Auth — grants "authGrantsInsert", "authGrantsGetForIssuance", @@ -750,11 +787,15 @@ export function loadReferenceQueries(queryDir = QUERIES_DIR): ReferenceQueryRegi "authGrantsListActiveIdsByClientId", // Auth — tokens "authTokensInsertClient", + "authTokensInsertRefreshClient", + "authTokensInsertRefreshMcpPackage", + "authTokensLinkRefreshFamily", "authTokensInsertOwner", "authTokensCountActiveByClientId", "authTokensGetIntrospection", "authTokensRevokeByGrant", "authTokensRevokeByClientId", + "authTokensRevokeByRefreshFamily", // Auth — grant_packages "authGrantPackagesListAll", "authGrantPackageMembersListAllByPackage", diff --git a/reference-implementation/server/record-expand-helpers.ts b/reference-implementation/server/record-expand-helpers.ts index c13d19a22..54d9f78cb 100644 --- a/reference-implementation/server/record-expand-helpers.ts +++ b/reference-implementation/server/record-expand-helpers.ts @@ -14,6 +14,8 @@ * (the parser and projection requirements that both backends share). */ +import { requireTimeConstraint, type TimeConstraint } from "./record-filters.ts"; + export type JsonObject = Record<string, unknown>; type RecordKey = unknown; class QueryError extends Error { @@ -26,14 +28,22 @@ class QueryError extends Error { } interface EffectiveFilterGrant { fields?: string[] | null; + instance_ids?: string[] | null; resources?: string[] | null; - time_range?: JsonObject | null; + time_constraint?: TimeConstraint | null; +} +export interface EffectiveFilter { + fields: string[] | null; + resources: string[] | null; + timeConstraint: TimeConstraint | null; + timeConstraintField: string | null; } interface ExpandGrant { streams: Array<{ name: string; fields?: string[] | null; - time_range?: JsonObject | null; + instance_ids?: string[] | null; + time_constraint?: TimeConstraint | null; resources?: string[] | null; }>; } @@ -161,32 +171,32 @@ export function parseIntegerValue(value: unknown): number | null { return Number.parseInt(value.trim(), 10); } -// JSON-path identifiers that come from the manifest are already validated by -// `validateConnectorManifest`, but we re-validate here with a tight regex so -// backends can only interpolate safely-quoted `$.<field>` paths into SQL. -export const SAFE_JSON_FIELD = /^[A-Za-z_][A-Za-z_0-9]*$/; - -export function assertSafeJsonField(field: unknown, label: string): void { - if (typeof field !== "string" || !SAFE_JSON_FIELD.test(field)) { - throw new Error(`[records] Unsafe JSON field ${label}: ${JSON.stringify(field)}`); +// A SourceDeclaration field reference names one literal top-level JSON key. +// It is not an identifier: punctuation and Unicode are valid key characters. +// SQL builders must quote or bind it as JSON data rather than interpolate it +// as SQL syntax. +export function assertNonEmptyJsonField(field: unknown, label: string): asserts field is string { + if (typeof field !== "string" || field.length === 0) { + throw new Error(`[records] JSON field ${label} must be a non-empty string: ${JSON.stringify(field)}`); } } /** * Build an effective filter from grant + request params. - * Returns { fields, timeRange, resources, consentTimeField } for use by + * Returns frozen grant fields, resources, and temporal constraint for use by * either the SQLite or Postgres record paths. */ export function buildEffectiveFilter( streamGrant: EffectiveFilterGrant, requestParams: RequestParams, requiredFields: string[] = [] -): JsonObject { +): EffectiveFilter { + const timeConstraint = requireTimeConstraint(streamGrant.time_constraint); const effective = { - consentTimeField: null, fields: streamGrant.fields || null, resources: streamGrant.resources || null, - timeRange: streamGrant.time_range || null, + timeConstraint, + timeConstraintField: timeConstraint ? timeConstraint.field : null, }; if (requestParams.fields && effective.fields !== null) { @@ -196,12 +206,37 @@ export function buildEffectiveFilter( } if (effective.fields !== null) { - effective.fields = [...new Set([...requiredFields, ...effective.fields])]; + // Resolved client grants carry instance_ids and freeze the field set at + // authorization time. A later manifest declaration must not widen that + // grant by adding newly-required fields. Owner grants omit instance_ids + // and retain the current-manifest behavior used by self-reads. + const manifestRequiredFields = Object.hasOwn(streamGrant, "instance_ids") ? [] : requiredFields; + effective.fields = [...new Set([...manifestRequiredFields, ...effective.fields])]; } return effective; } +/** + * Expansion children are stored in the same source instance as their parent. + * A resolved client grant therefore authorizes an expansion only when the + * child stream's closed instance set contains the selected parent instance. + * Owner grants omit instance_ids and keep their unrestricted self-read path. + */ +export function assertExpansionInstanceAuthorized(childGrant: EffectiveFilterGrant, connectorInstanceId: string): void { + if (!Object.hasOwn(childGrant, "instance_ids")) { + return; + } + if (!(Array.isArray(childGrant.instance_ids) && childGrant.instance_ids.includes(connectorInstanceId))) { + const error = invalidQueryError( + "The expanded stream is not authorized on the selected connection.", + "connection_not_found" + ); + error.param = "connection_id"; + throw error; + } +} + /** * Validate the `expand[]` / `expand_limit[]` request shape against the * parent stream's manifest-declared `relationships` + `query.expand` diff --git a/reference-implementation/server/record-filters.ts b/reference-implementation/server/record-filters.ts index cfb6d59c2..495a91f68 100644 --- a/reference-implementation/server/record-filters.ts +++ b/reference-implementation/server/record-filters.ts @@ -12,7 +12,7 @@ type JsonObject = Record<string, unknown>; interface StreamGrant { fields?: string[] | null; resources?: string[]; - time_range?: { since?: string; until?: string } | null; + time_constraint?: TimeConstraint | null; } interface ManifestStream { consent_time_field?: string; @@ -55,12 +55,51 @@ class QueryError extends Error { } } +export interface TimeConstraint { + field: string; + since?: string; + until?: string; +} + +interface TimeRange { + since?: string; + until?: string; +} + +class GrantConstraintError extends Error { + readonly code = "grant_invalid"; +} + const SUPPORTED_RANGE_OPERATORS = new Set(["gte", "gt", "lte", "lt"]); export function invalidQueryError(message: string, code = "invalid_request"): Error & { code: string } { return new QueryError(message, code); } +/** + * v0.1 client grants do not expose request-time filters or retain relationship + * authorization. Keep this guard independent from manifest parsing so current + * declaration metadata cannot reinterpret an issued grant. Owner reads retain + * the current-capability query paths. + */ +export function rejectUnsupportedClientQuery(tokenKind: string | null | undefined, requestParams: unknown): void { + if (tokenKind !== "client" || !requestParams || typeof requestParams !== "object" || Array.isArray(requestParams)) { + return; + } + const params = requestParams as Record<string, unknown>; + const unsupported = [ + { keys: ["expand", "expand[]"], message: "expand[]", param: "expand" }, + { keys: ["expand_limit", "expand_limit[]"], message: "expand_limit[...]", param: "expand_limit" }, + { keys: ["filter"], message: "filter[...]", param: "filter" }, + ].find(({ keys }) => keys.some((key) => Object.hasOwn(params, key))); + if (!unsupported) { + return; + } + const error = invalidQueryError(`${unsupported.message} is not supported for client-token reads in PDPP v0.1`); + Object.assign(error, { param: unsupported.param }); + throw error; +} + export function getFieldSchema(manifestStream: ManifestStream | null | undefined, field: string): Schema | null { return manifestStream?.schema?.properties?.[field] || null; } @@ -135,6 +174,36 @@ export function parseDateValue(value: unknown): number | null { return Number.isFinite(parsed) ? parsed : null; } +export function requireTimeConstraint(value: unknown): TimeConstraint | null { + if (value === undefined || value === null) { + return null; + } + if (typeof value !== "object" || Array.isArray(value)) { + throw new GrantConstraintError("Grant time_constraint must be an object"); + } + const constraint = value as Record<string, unknown>; + const unsupported = Object.keys(constraint).filter((key) => !["field", "since", "until"].includes(key)); + const field = typeof constraint.field === "string" ? constraint.field : ""; + const { since, until } = constraint; + const sinceMs = since === undefined ? null : parseDateValue(since); + const untilMs = until === undefined ? null : parseDateValue(until); + if ( + unsupported.length > 0 || + !field || + (since === undefined && until === undefined) || + (since !== undefined && sinceMs === null) || + (until !== undefined && untilMs === null) || + (sinceMs !== null && untilMs !== null && sinceMs > untilMs) + ) { + throw new GrantConstraintError("Grant time_constraint is malformed"); + } + return { + field, + ...(typeof since === "string" ? { since } : {}), + ...(typeof until === "string" ? { until } : {}), + }; +} + export function coerceComparableValue( value: unknown, fieldSchema: Schema | null | undefined, @@ -195,8 +264,7 @@ function compileRangeFilter( throw invalidQueryError(`Range filters are not supported on '${field}'`); } - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - const declaredOperators = manifestStream?.query?.range_filters?.[field]; + const declaredOperators = manifestStream.query?.range_filters?.[field]; if (!(Array.isArray(declaredOperators) && declaredOperators.length)) { throw invalidQueryError(`Range filters are not declared for '${field}'`); } @@ -310,7 +378,7 @@ export function passesRequestFilters( export function passesTimeRange( data: JsonObject | null, - timeRange: StreamGrant["time_range"], + timeRange: TimeRange | null | undefined, consentTimeField: string | null | undefined ): boolean { if (!(timeRange && consentTimeField)) { @@ -333,17 +401,30 @@ export function passesTimeRange( return true; } +export function passesTimeConstraint(data: JsonObject | null, value: unknown): boolean { + const constraint = requireTimeConstraint(value); + if (!constraint) { + return true; + } + const recordTime = parseDateValue(data?.[constraint.field]); + if (recordTime === null) { + return false; + } + const since = constraint.since === undefined ? null : parseDateValue(constraint.since); + const until = constraint.until === undefined ? null : parseDateValue(constraint.until); + return !((since !== null && recordTime < since) || (until !== null && recordTime >= until)); +} + export function passesGrantRecordConstraints( data: JsonObject | null, recordKey: string, streamGrant: StreamGrant | null | undefined, - manifestStream: ManifestStream + _manifestStream: ManifestStream ): boolean { if (streamGrant?.resources?.length && !streamGrant.resources.includes(recordKey)) { return false; } - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - return passesTimeRange(data, streamGrant?.time_range, manifestStream?.consent_time_field); + return passesTimeConstraint(data, streamGrant?.time_constraint); } export function compileSingleStreamSearchFilter({ @@ -420,7 +501,10 @@ export function hashSearchPlanSummary({ } export function hasGrantRecordConstraints(streamGrant: StreamGrant | null | undefined): boolean { - return !!(streamGrant?.time_range || (Array.isArray(streamGrant?.resources) && streamGrant.resources.length > 0)); + return !!( + streamGrant?.time_constraint || + (Array.isArray(streamGrant?.resources) && streamGrant.resources.length > 0) + ); } export function needsCandidateRecordScan( diff --git a/reference-implementation/server/record-ingest-semantic-time.ts b/reference-implementation/server/record-ingest-semantic-time.ts index 5ff6a540a..d2ecb0ed6 100644 --- a/reference-implementation/server/record-ingest-semantic-time.ts +++ b/reference-implementation/server/record-ingest-semantic-time.ts @@ -55,10 +55,6 @@ interface ParsedManifest { // ingest and search coerce timestamps identically. export const SEMANTIC_TIME_EPOCH_MS_THRESHOLD = 1e12; -// A valid SQL/manifest field identifier: a letter or underscore followed by -// word characters. Used to reject injection-shaped consent_time_field names. -const FIELD_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; - export function getManifestConsentTimeField(connectorId: string, streamName: string): string | null { const row = getOne<ManifestRow>(referenceQueries.authConnectorsGetManifestById, [connectorId]); if (!row?.manifest) { @@ -79,7 +75,7 @@ export function getManifestConsentTimeField(connectorId: string, streamName: str if (typeof field !== "string" || !field) { return null; } - return FIELD_IDENTIFIER_PATTERN.test(field) ? field : null; + return field; } // Coerce a manifest-declared timestamp field value to a clean ISO-8601 string, diff --git a/reference-implementation/server/records.ts b/reference-implementation/server/records.ts index c3c5540b2..5c31f5a1b 100644 --- a/reference-implementation/server/records.ts +++ b/reference-implementation/server/records.ts @@ -52,6 +52,7 @@ import { } from "./connection-id-request.ts"; import { AmbiguousConnectionError, + listActiveBindingsForGrant, projectBindingForWire, resolveRecordIdentityForBinding, resolveRequestBindings, @@ -64,7 +65,6 @@ import { import { canonicalConnectorKey } from "./connector-key.ts"; import { markConnectorSummaryEvidenceDirty } from "./connector-summary-read-model.ts"; import { applyDatasetSummaryRecordDelta, markDatasetSummaryProjectionStale } from "./dataset-summary-read-model.ts"; -import { OWNER_AUTH_DEFAULT_SUBJECT_ID } from "./owner-auth.ts"; import { postgresDeleteAllRecords, postgresDeleteRecord, @@ -83,8 +83,9 @@ import { } from "./postgres-records.ts"; import { isPostgresStorageBackend, postgresQuery, withPostgresTransaction } from "./postgres-storage.ts"; import { + assertExpansionInstanceAuthorized, + assertNonEmptyJsonField, assertRecordIdentity, - assertSafeJsonField, buildEffectiveFilter, type ExpandResult, invalidQueryError, @@ -102,7 +103,14 @@ import { normalizeWindowSelector, sqliteFieldJsonPath, } from "./record-field-window.ts"; -import { type CompiledFilter, compileRequestFilters, passesRequestFilters, passesTimeRange } from "./record-filters.ts"; +import { + type CompiledFilter, + compileRequestFilters, + jsonPathForTopLevelField, + passesRequestFilters, + passesTimeConstraint, + type TimeConstraint, +} from "./record-filters.ts"; import { applyRetainedSizeRecordDelta, markRetainedSizeConnectionDirty, @@ -115,7 +123,6 @@ import { resolveStorageConnectorId, resolveStorageConnectorInstanceId, } from "./storage-utils.ts"; -import { makeDefaultAccountConnectorInstanceId } from "./stores/connector-instance-store.ts"; import { getDefaultConnectorStateStore } from "./stores/connector-state-store.ts"; import { advanceSqliteDeviceIngestPrefix } from "./stores/device-exporter-store.ts"; @@ -272,15 +279,16 @@ interface ManifestStream { } type RequestParams = Record<string, unknown>; interface StreamGrant { - connection_id?: string; fields?: string[] | null; + instance_ids?: string[]; resources?: string[]; - time_range?: { since?: string; until?: string } | null; + time_constraint?: TimeConstraint | null; } interface EffectiveReadScope { fields: string[] | null; resources: string[] | null; - timeRange: { since?: string; until?: string } | null; + timeConstraint: TimeConstraint | null; + timeConstraintField: string | null; } interface StoredRecordRow { __fk?: unknown; @@ -559,11 +567,11 @@ interface StorageBinding { } interface ReadRequestBindingsArgs { grant: ReadGrant; - nativeProviderStorage?: boolean; + ownerRead?: boolean; ownerSubjectId?: string; requestParams: RequestParams; storageBinding?: StorageBinding | null; - streamName: string; + streamName: string | null; } interface SyncStateOptions { allowedStreams?: Iterable<string> | null; @@ -588,7 +596,6 @@ interface LogicalPosition { primary_key?: unknown[]; } type PageOrder = "ASC" | "DESC"; -const SAFE_JSON_FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; interface PaginationCursor { after_version?: number; cursor_value?: unknown; @@ -608,7 +615,6 @@ function buildEffectiveReadScope( requiredFields: string[] = [] ): EffectiveReadScope { const effective = buildEffectiveFilter(streamGrant, requestParams, requiredFields); - const rawTimeRange = effective.timeRange; return { fields: Array.isArray(effective.fields) ? effective.fields.filter((field): field is string => typeof field === "string") @@ -616,12 +622,8 @@ function buildEffectiveReadScope( resources: Array.isArray(effective.resources) ? effective.resources.filter((resource): resource is string => typeof resource === "string") : null, - timeRange: isRecordData(rawTimeRange) - ? { - ...(typeof rawTimeRange.since === "string" ? { since: rawTimeRange.since } : {}), - ...(typeof rawTimeRange.until === "string" ? { until: rawTimeRange.until } : {}), - } - : null, + timeConstraint: effective.timeConstraint, + timeConstraintField: effective.timeConstraintField, }; } @@ -634,7 +636,9 @@ interface ClientEventChange { connectionId: string; connectorId: string; connectorInstanceId: string; + data: Record<string, unknown> | null; emittedAt: string; + recordKey: string; stream: string; version: number | null; } @@ -1332,7 +1336,12 @@ async function ingestPostgresRecord( connectionId: connectorInstanceId, connectorId, connectorInstanceId, + data: + record.data && typeof record.data === "object" && !Array.isArray(record.data) + ? (record.data as Record<string, unknown>) + : null, emittedAt: record.emitted_at ?? nowIso(), + recordKey: encodeKey(record.key), stream, version: outcome.version ?? null, }); @@ -1626,7 +1635,12 @@ async function ingestSqliteRecord( connectionId: connectorInstanceId, connectorId, connectorInstanceId, + data: + record.data && typeof record.data === "object" && !Array.isArray(record.data) + ? (record.data as Record<string, unknown>) + : null, emittedAt: effectiveEmittedAt, + recordKey: encodeKey(record.key), stream, version: outcome.version, }); @@ -2718,9 +2732,38 @@ function normalizeAggregateRequest( } function jsonExtractExpr(field: string): string { - assertSafeJsonField(field, "json_extract"); - // record_json is our JSON TEXT column; $.<field> is the JSONPath. - return `json_extract(record_json, '$.${field}')`; + return sqliteTopLevelJsonExpr(field); +} + +function sqliteTopLevelJsonExpr(field: string): string { + assertNonEmptyJsonField(field, "json_extract"); + const path = jsonPathForTopLevelField(field).replace(/'/g, "''"); + return `json_extract(record_json, '${path}')`; +} + +function appendSqliteGrantTimeConstraint( + whereParts: string[], + whereBinds: BindValue[], + effective: EffectiveReadScope +): void { + const constraint = effective.timeConstraint; + if (!constraint) { + return; + } + + const timeExpr = sqliteTopLevelJsonExpr(constraint.field); + const path = jsonPathForTopLevelField(constraint.field).replace(/'/g, "''"); + const julianExpr = `julianday(${timeExpr})`; + whereParts.push(`json_type(record_json, '${path}') = 'text'`); + whereParts.push(`${julianExpr} IS NOT NULL`); + if (constraint.since !== undefined) { + whereParts.push(`${julianExpr} >= julianday(?)`); + whereBinds.push(constraint.since); + } + if (constraint.until !== undefined) { + whereParts.push(`${julianExpr} < julianday(?)`); + whereBinds.push(constraint.until); + } } /** @@ -2899,7 +2942,7 @@ function buildCursorSeekClause( * change). * * Contract: - * - Access-control filters (time_range, resources) are applied in SQL. + * - Access-control filters (time_constraint, resources) are applied in SQL. * - ORDER BY is applied in SQL, reproducing `compareLogicalPositions`. * - Cursor-based seek is applied in SQL; no result is materialized for * rows before the cursor. @@ -2935,25 +2978,11 @@ function fetchVisibleRecordRowsInMemory({ limit, order, }: FetchVisibleRecordsArgs): { hasMore: boolean; rows: VisibleRecordRow[]; scanned: number; underread: false } { - const consentTimeField = manifestStream?.consent_time_field; - // Access-control pushdown: keep the same WHERE shape the SQL path uses, just // without ORDER BY / LIMIT / cursor-seek. const whereParts = ["connector_instance_id = ?", "stream = ?", "deleted = 0"]; const whereBinds = [connectorInstanceId, stream]; - if (effective.timeRange && consentTimeField) { - assertSafeJsonField(consentTimeField, "consent_time_field"); - const ctExpr = jsonExtractExpr(consentTimeField); - whereParts.push(`${ctExpr} IS NOT NULL`); - if (!isNullish(effective.timeRange.since)) { - whereParts.push(`${ctExpr} >= ?`); - whereBinds.push(new Date(effective.timeRange.since).toISOString()); - } - if (!isNullish(effective.timeRange.until)) { - whereParts.push(`${ctExpr} < ?`); - whereBinds.push(new Date(effective.timeRange.until).toISOString()); - } - } + appendSqliteGrantTimeConstraint(whereParts, whereBinds, effective); if (effective.resources && effective.resources.length > 0) { const placeholders = effective.resources.map(() => "?").join(", "); whereParts.push(`record_key IN (${placeholders})`); @@ -2967,7 +2996,7 @@ function fetchVisibleRecordRowsInMemory({ `; // REVIEWED-DYNAMIC: in-memory fallback for streams whose cursor_field is - // not SQL-safe; WHERE clause varies with grant time_range / resources; + // not SQL-safe; WHERE clause varies with grant time_constraint / resources; // intentionally no LIMIT — JS sort/seek needs the full visible set. const visible: VisibleRecordRow[] = []; for (const row of iterateDynamicSqlAcknowledged<StoredRecordRow>(sql, whereBinds)) { @@ -3012,7 +3041,6 @@ function buildPaginatedSqlParts( cursorPosition: Required<LogicalPosition> | null, order: PageOrder ): PaginatedSqlParts { - const consentTimeField = manifestStream?.consent_time_field; const cursorField = manifestStream?.cursor_field || null; const primaryKeyFields = normalizePrimaryKey(manifestStream?.primary_key); if (primaryKeyFields.length === 0) { @@ -3034,19 +3062,7 @@ function buildPaginatedSqlParts( const whereParts = ["connector_instance_id = ?", "stream = ?", "deleted = 0"]; const whereBinds: BindValue[] = [connectorInstanceId, stream]; - if (effective.timeRange && consentTimeField) { - assertSafeJsonField(consentTimeField, "consent_time_field"); - const ctExpr = jsonExtractExpr(consentTimeField); - whereParts.push(`${ctExpr} IS NOT NULL`); - if (!isNullish(effective.timeRange.since)) { - whereParts.push(`${ctExpr} >= ?`); - whereBinds.push(new Date(effective.timeRange.since).toISOString()); - } - if (!isNullish(effective.timeRange.until)) { - whereParts.push(`${ctExpr} < ?`); - whereBinds.push(new Date(effective.timeRange.until).toISOString()); - } - } + appendSqliteGrantTimeConstraint(whereParts, whereBinds, effective); if (effective.resources && effective.resources.length > 0) { const placeholders = effective.resources.map(() => "?").join(", "); whereParts.push(`record_key IN (${placeholders})`); @@ -3135,7 +3151,7 @@ function fetchVisibleRecordRowsPaginated({ LIMIT ? `; - // REVIEWED-DYNAMIC: WHERE clause varies with grant time_range / resources + // REVIEWED-DYNAMIC: WHERE clause varies with grant time_constraint / resources // / cursor seek; SQL composed in JS as today; LIMIT N+1 included. const collected: VisibleRecordRow[] = []; let scanned = 0; @@ -3231,6 +3247,7 @@ function hydrateExpandedRelations({ } for (const expansion of expansions) { + assertExpansionInstanceAuthorized(expansion.childGrant, connectorInstanceId); const childManifestStream = manifest?.streams?.find((entry) => entry.name === expansion.relationship.stream); const childRequiredFields = childManifestStream?.schema?.required ?? []; const childEffective = buildEffectiveReadScope(expansion.childGrant, {}, childRequiredFields); @@ -3301,13 +3318,13 @@ function assignExpansionToParentRow( * Slice-2 replacement for the per-child full-scan. Builds one window-function * SQL query that: * - narrows by `foreign_key IN (?, ?, ...)` to the current parent page, - * - applies the child grant's access-control filters (time_range, resources) + * - applies the child grant's access-control filters (time_constraint, resources) * in SQL, * - assigns ROW_NUMBER() per foreign-key partition ordered by the child's * manifest-declared (cursor_field, primary_key) basis, * - clips the per-partition rank to (has_many: limit + 1) or (has_one: 1). * - * Grant filtering stays in SQL: the child's time_range/resources come from + * Grant filtering stays in SQL: the child's time_constraint/resources come from * `childEffective` (derived from `expansion.childGrant`) and are pushed into * WHERE exactly as the primary path does. * @@ -3335,7 +3352,6 @@ function fetchExpansionChildrenGroupedByForeignKeyInMemory({ return result; } - const consentTimeField = childManifestStream?.consent_time_field; const primaryKeyFields = normalizePrimaryKey(childManifestStream?.primary_key); if (primaryKeyFields.length === 0) { throw new Error("[records] child stream manifest primary_key is required for expansion"); @@ -3343,25 +3359,12 @@ function fetchExpansionChildrenGroupedByForeignKeyInMemory({ const whereParts = ["connector_instance_id = ?", "stream = ?", "deleted = 0"]; const whereBinds = [connectorInstanceId, childStream]; - if (childEffective.timeRange && consentTimeField) { - assertSafeJsonField(consentTimeField, "consent_time_field"); - const ctExpr = jsonExtractExpr(consentTimeField); - whereParts.push(`${ctExpr} IS NOT NULL`); - if (!isNullish(childEffective.timeRange.since)) { - whereParts.push(`${ctExpr} >= ?`); - whereBinds.push(new Date(childEffective.timeRange.since).toISOString()); - } - if (!isNullish(childEffective.timeRange.until)) { - whereParts.push(`${ctExpr} < ?`); - whereBinds.push(new Date(childEffective.timeRange.until).toISOString()); - } - } + appendSqliteGrantTimeConstraint(whereParts, whereBinds, childEffective); if (childEffective.resources && childEffective.resources.length > 0) { const placeholders = childEffective.resources.map(() => "?").join(", "); whereParts.push(`record_key IN (${placeholders})`); whereBinds.push(...childEffective.resources); } - assertSafeJsonField(foreignKeyField, "foreign_key"); const fkExpr = jsonExtractExpr(foreignKeyField); const parentPlaceholders = parentKeys.map(() => "?").join(", "); whereParts.push(`${fkExpr} IN (${parentPlaceholders})`); @@ -3374,7 +3377,7 @@ function fetchExpansionChildrenGroupedByForeignKeyInMemory({ // REVIEWED-DYNAMIC: in-memory expansion fallback for child streams whose // cursor_field is not SQL-safe; WHERE clause varies with child grant - // time_range / resources and parent foreign-key IN-list; intentionally no + // time_constraint / resources and parent foreign-key IN-list; intentionally no // LIMIT — JS sort/per-parent slice needs the full visible child set for // the parent page. const rankBound = cardinality === "has_one" ? 1 : limit + 1; @@ -3416,7 +3419,6 @@ function fetchExpansionChildrenGroupedByForeignKey({ return result; } - assertSafeJsonField(foreignKeyField, "foreign_key"); // If the child stream's cursor_field isn't SQL-safe, fall back to an // in-memory per-foreign-key group so the expansion still hydrates. Rare in // practice (expansion streams are typically the narrow, well-typed ones), @@ -3452,7 +3454,6 @@ function fetchExpansionChildrenGroupedByForeignKey({ } const pkExpr = jsonExtractExpr(primaryKeyField); const cursorField = childManifestStream?.cursor_field || null; - const consentTimeField = childManifestStream?.consent_time_field; const orderByParts: string[] = []; if (cursorField) { @@ -3466,20 +3467,8 @@ function fetchExpansionChildrenGroupedByForeignKey({ const whereParts = ["connector_instance_id = ?", "stream = ?", "deleted = 0"]; const whereBinds = [connectorInstanceId, childStream]; - // time_range pushdown — same shape as fetchVisibleRecordRowsPaginated. - if (childEffective.timeRange && consentTimeField) { - assertSafeJsonField(consentTimeField, "consent_time_field"); - const ctExpr = jsonExtractExpr(consentTimeField); - whereParts.push(`${ctExpr} IS NOT NULL`); - if (!isNullish(childEffective.timeRange.since)) { - whereParts.push(`${ctExpr} >= ?`); - whereBinds.push(new Date(childEffective.timeRange.since).toISOString()); - } - if (!isNullish(childEffective.timeRange.until)) { - whereParts.push(`${ctExpr} < ?`); - whereBinds.push(new Date(childEffective.timeRange.until).toISOString()); - } - } + // time_constraint pushdown has the same shape as fetchVisibleRecordRowsPaginated. + appendSqliteGrantTimeConstraint(whereParts, whereBinds, childEffective); // resources pushdown. if (childEffective.resources && childEffective.resources.length > 0) { @@ -3517,7 +3506,7 @@ function fetchExpansionChildrenGroupedByForeignKey({ `; // REVIEWED-DYNAMIC: SQL-pushdown expansion; WHERE clause varies with - // child grant time_range / resources and parent foreign-key IN-list; + // child grant time_constraint / resources and parent foreign-key IN-list; // ORDER BY varies with the child manifest's cursor_field / // primary_key; per-partition rank bound (__rn <= ?) caps each parent's // child set instead of a top-level LIMIT. @@ -3539,22 +3528,14 @@ function fetchExpansionChildrenGroupedByForeignKey({ return result; } -function isVisibleSnapshot( - snapshot: RecordSnapshot | null, - effective: EffectiveReadScope, - consentTimeField: string | null | undefined -): boolean { +function isVisibleSnapshot(snapshot: RecordSnapshot | null, effective: EffectiveReadScope): boolean { if (!snapshot || snapshot.deleted || !snapshot.data) { return false; } if (effective.resources && !effective.resources.includes(snapshot.record_key)) { return false; } - if ( - effective.timeRange && - consentTimeField && - !passesTimeRange(snapshot.data, effective.timeRange, consentTimeField) - ) { + if (!passesTimeConstraint(snapshot.data, effective.timeConstraint)) { return false; } return true; @@ -4020,7 +4001,6 @@ function collectVisibleChanges( verifiedAfterVersion: number, effectiveSessionMaxVersion: number, effective: EffectiveReadScope, - consentTimeField: string | undefined, compiledFilters: CompiledFilter[], recordIdentity: RecordIdentity | null, limit: number @@ -4049,7 +4029,6 @@ function collectVisibleChanges( stream, verifiedSinceVersion, effective, - consentTimeField, compiledFilters, recordIdentity ); @@ -4080,15 +4059,14 @@ function resolveChangeGroupRecord( stream: string, verifiedSinceVersion: number, effective: EffectiveReadScope, - consentTimeField: string | undefined, compiledFilters: CompiledFilter[], recordIdentity: RecordIdentity | null ): ResponseRecord | null { const previous = getSnapshotAtVersion(connectorInstanceId, stream, group.record_key, verifiedSinceVersion); const current = getSnapshotAtVersion(connectorInstanceId, stream, group.record_key, group.latest_version); - const previousVisible = isVisibleSnapshot(previous, effective, consentTimeField); - const currentVisible = isVisibleSnapshot(current, effective, consentTimeField); + const previousVisible = isVisibleSnapshot(previous, effective); + const currentVisible = isVisibleSnapshot(current, effective); if (current?.deleted) { if (!(previousVisible && previous?.data && passesRequestFilters(previous.data, compiledFilters))) { @@ -4133,7 +4111,6 @@ interface QueryRecordsChangesSinceArgs { changesSince: ChangesSinceCursor | null; compiledFilters: CompiledFilter[]; connectorInstanceId: string; - consentTimeField: string | undefined; effective: EffectiveReadScope; limit: number; paginationCursor: PaginationCursor | null; @@ -4146,7 +4123,6 @@ function queryRecordsChangesSince({ changesSince, compiledFilters, connectorInstanceId, - consentTimeField, effective, limit, paginationCursor, @@ -4168,7 +4144,6 @@ function queryRecordsChangesSince({ verifiedAfterVersion, effectiveSessionMaxVersion, effective, - consentTimeField, compiledFilters, recordIdentity, limit @@ -4282,7 +4257,6 @@ async function buildPagedRecordsResponse({ const countOutcome = computeGradedRecordCount({ compiledFilters, connectorInstanceId, - consentTimeField, effective, requestParams, stream, @@ -4437,7 +4411,6 @@ export async function queryRecords( changesSince: ctx.changesSince, compiledFilters: ctx.compiledFilters, connectorInstanceId: ctx.connectorInstanceId, - consentTimeField: ctx.consentTimeField, effective: ctx.effective, limit: ctx.limit, paginationCursor: ctx.paginationCursor, @@ -4494,11 +4467,9 @@ function computeGradedRecordCount({ stream, effective, compiledFilters, - consentTimeField, }: { compiledFilters: readonly CompiledFilter[]; connectorInstanceId: string; - consentTimeField: string | null | undefined; effective: EffectiveReadScope; requestParams: RequestParams; stream: string; @@ -4511,7 +4482,6 @@ function computeGradedRecordCount({ const exactValue = countVisibleRecordsForStream({ compiledFilters, connectorInstanceId, - consentTimeField, effective, stream, }); @@ -4533,11 +4503,9 @@ function countVisibleRecordsForStream({ stream, effective, compiledFilters, - consentTimeField, }: { compiledFilters: readonly CompiledFilter[]; connectorInstanceId: string; - consentTimeField: string | null | undefined; effective: EffectiveReadScope; stream: string; }): number { @@ -4558,7 +4526,7 @@ function countVisibleRecordsForStream({ if (effective.resources && !effective.resources.includes(row.record_key)) { continue; } - if (effective.timeRange && consentTimeField && !passesTimeRange(rawData, effective.timeRange, consentTimeField)) { + if (!passesTimeConstraint(rawData, effective.timeConstraint)) { continue; } if (compiledFilters.length && !passesRequestFilters(rawData, compiledFilters)) { @@ -4596,13 +4564,12 @@ function isVisibleAggregateRow( row: AggregateRecordRow, rawData: RecordData, effective: EffectiveReadScope, - consentTimeField: string | null | undefined, compiledFilters: readonly CompiledFilter[] ): boolean { if (effective.resources && !effective.resources.includes(row.record_key)) { return false; } - if (effective.timeRange && consentTimeField && !passesTimeRange(rawData, effective.timeRange, consentTimeField)) { + if (!passesTimeConstraint(rawData, effective.timeConstraint)) { return false; } return compiledFilters.length === 0 || passesRequestFilters(rawData, compiledFilters); @@ -4626,8 +4593,9 @@ function recordWindowTime(value: unknown): number | null { * resources, time-range, and compiled filters), so the two surfaces stay in * lock-step and we never duplicate grant/filter semantics on a divergent path. * - * Timestamp source is the stream's logical `consent_time_field` — the same - * field `passesTimeRange` filters on — never the storage ingest `emitted_at`. + * Timestamp source for metadata remains the stream's logical + * `consent_time_field`, never the storage ingest `emitted_at`. Authorization + * independently uses the frozen grant `time_constraint.field`. * * Honest-omission rules (never estimate; see spec scenario "Window metadata is * omitted rather than estimated"): @@ -4682,7 +4650,7 @@ function computeRecordWindow({ for (const row of rows) { const rawData = parseStoredRecordData(row.record_json); - if (!isVisibleAggregateRow(row, rawData, effective, consentTimeField, compiledFilters)) { + if (!isVisibleAggregateRow(row, rawData, effective, compiledFilters)) { continue; } @@ -4886,7 +4854,6 @@ export async function aggregateRecords( enforceConnectionNarrowing(requestParams, connectorInstanceId); const compiledFilters = compileRequestFilters(requestParams.filter, streamGrant, manifestStream); const effective = buildEffectiveReadScope(streamGrant, {}); - const consentTimeField = manifestStream?.consent_time_field || null; const rows = await listRowsForAggregation(connectorInstanceId, stream); @@ -4906,7 +4873,7 @@ export async function aggregateRecords( if (effective.resources && !effective.resources.includes(row.record_key)) { continue; } - if (effective.timeRange && consentTimeField && !passesTimeRange(rawData, effective.timeRange, consentTimeField)) { + if (!passesTimeConstraint(rawData, effective.timeConstraint)) { continue; } if (compiledFilters.length && !passesRequestFilters(rawData, compiledFilters)) { @@ -4991,14 +4958,13 @@ export async function getRecord( } const rawData = parseStoredRecordData(row.record_json); - const consentTimeField = mStream?.consent_time_field; const requiredFields = mStream?.schema?.required || []; const effective = buildEffectiveReadScope(streamGrant, {}, requiredFields); if (effective.resources && !effective.resources.includes(row.record_key)) { throw codedError("Record not found", "not_found"); } - if (effective.timeRange && consentTimeField && !passesTimeRange(rawData, effective.timeRange, consentTimeField)) { + if (!passesTimeConstraint(rawData, effective.timeConstraint)) { throw codedError("Record not found", "not_found"); } @@ -5072,15 +5038,14 @@ function assertFieldWindowManifestAuthority(manifest: ReadManifest | null, strea function assertFieldWindowRecordVisible( row: RecordFieldWindowRow, - effective: ReturnType<typeof buildEffectiveReadScope>, - consentTimeField: string | undefined + effective: ReturnType<typeof buildEffectiveReadScope> ): void { if (effective.resources && !effective.resources.includes(row.record_key)) { throw fieldWindowError("not_found", "Record not found", 404); } - if (effective.timeRange && consentTimeField) { - const consentData = { [consentTimeField]: row.consent_time_value }; - if (!passesTimeRange(consentData, effective.timeRange, consentTimeField)) { + if (effective.timeConstraint && effective.timeConstraintField) { + const constrainedData = { [effective.timeConstraintField]: row.consent_time_value }; + if (!passesTimeConstraint(constrainedData, effective.timeConstraint)) { throw fieldWindowError("not_found", "Record not found", 404); } } @@ -5164,25 +5129,24 @@ export async function getRecordFieldWindow( enforceConnectionNarrowing(requestParams, connectorInstanceId); const mStream = manifest?.streams?.find((s) => s.name === stream); - const consentTimeField = mStream?.consent_time_field; const requiredFields = mStream?.schema?.required || []; const effective = buildEffectiveReadScope(streamGrant, {}, requiredFields); assertFieldVisibleToGrant(fieldPath, effective.fields); const fieldPathExpr = sqliteFieldJsonPath(fieldPath); - const consentPathExpr = consentTimeField ? sqliteFieldJsonPath(consentTimeField) : null; + const constraintPathExpr = effective.timeConstraintField ? sqliteFieldJsonPath(effective.timeConstraintField) : null; const row = getOne<RecordFieldWindowRow>( referenceQueries.recordsGetFieldWindow, - buildFieldWindowBinds(fieldPathExpr, consentPathExpr, connectorInstanceId, stream, recordId, selector) + buildFieldWindowBinds(fieldPathExpr, constraintPathExpr, connectorInstanceId, stream, recordId, selector) ); if (!row) { throw fieldWindowError("not_found", "Record not found", 404); } - assertFieldWindowRecordVisible(row, effective, consentTimeField); + assertFieldWindowRecordVisible(row, effective); const fieldClass = classifyFieldType(row.field_type); assertReadableStringField(fieldPath, fieldClass); @@ -5841,14 +5805,12 @@ export async function listStreams( sg.name, ]); const effective = buildEffectiveReadScope(sg, {}); - const manifestStream = manifest?.streams?.find((stream) => stream.name === sg.name); - const consentTimeField = manifestStream?.consent_time_field || null; let visibleCount = 0; let lastUpdated: string | null = null; for (const row of rows) { const rawData = parseStoredRecordData(row.record_json); - if (effective.timeRange && consentTimeField && !passesTimeRange(rawData, effective.timeRange, consentTimeField)) { + if (!passesTimeConstraint(rawData, effective.timeConstraint)) { continue; } if (effective.resources && !effective.resources.includes(row.record_key)) { @@ -5880,7 +5842,8 @@ export async function listStreams( // `getBlob`-style flows) with the canonical (connection_id, stream) // addressing rule from the public read contract: // -// - omitted `connection_id` SHALL fan in across the granted connections; +// - the grant's closed `instance_ids` set is the fan-in upper bound; +// - an omitted request-time `connection_id` reads that full set; // - exactly one matching connection SHALL be auto-selected; // - record/blob identifier ambiguity SHALL raise the typed // `ambiguous_connection` error with `available_connections`. @@ -6692,11 +6655,10 @@ function compareMergedBuckets(left: AggregateGroup, right: AggregateGroup, isSca * pre-existing shape with `connection_id`/`display_name` populated from * the sole active binding. * - * When the grant pins per-stream `connection_id`, those streams resolve - * against the named binding(s) only; streams without the constraint fan - * in across `defaultBindings`. The `resolveBindingsForStream` callback - * lets the route adapter apply the same `(request connection_id, grant - * per-stream connection_id)` rules per stream. When callers do not pass + * The grant's per-stream `instance_ids` are the authority, and the request's + * optional `connection_id` may narrow that set. The + * `resolveBindingsForStream` callback lets the route adapter apply those + * rules per stream. When callers do not pass * a resolver, the helper falls back to using `defaultBindings` for every * stream (preserving the prior single-resolution behavior for callers * that do not need per-stream constraint accuracy). @@ -6748,9 +6710,8 @@ export async function listStreamsAcrossBindings( return summaries; } - // Per-stream resolver path: each stream's bindings honor its own - // grant-scope `connection_id` constraint. Streams whose grant entry - // pins different connections do not bleed each other's counts. + // Per-stream resolver path: each stream honors its own closed instance_ids + // authority. Streams with different authorized sets do not bleed counts. const namedGrants = grantStreams.filter((sg) => sg?.name); const perStreamResults = await mapWithConcurrency( namedGrants, @@ -6859,8 +6820,8 @@ export async function getStreamDetailAcrossBindings( * should iterate. `warnings` contains the deprecated-alias warning when * the caller used `connector_instance_id` on the wire. * - * Honors per-stream `grant.streams[].connection_id` when present; absent - * constraint preserves cross-connection (fan-in) semantics. + * Client instance authority comes only from the selected stream's required + * `grant.streams[].instance_ids`. Owner reads opt into the active owner set. */ export async function resolveReadRequestBindings({ ownerSubjectId, @@ -6868,7 +6829,7 @@ export async function resolveReadRequestBindings({ grant, requestParams, streamName, - nativeProviderStorage = false, + ownerRead = false, }: ReadRequestBindingsArgs) { // Canonicalize the storage binding's connector_id at the shared admission // boundary. A grant or owner storage binding may still carry the legacy @@ -6881,44 +6842,24 @@ export async function resolveReadRequestBindings({ // Decision 1: storage bindings and grants key by connector_key. const rawConnectorId = storageBinding?.connector_id || null; const connectorId = rawConnectorId ? (canonicalConnectorKey(rawConnectorId) ?? rawConnectorId) : null; - if (nativeProviderStorage && connectorId) { - const { connectionId } = resolveRequestConnectionId(requestParams); - if (connectionId) { - const err: CodedReadError = Object.assign( - new Error("connection_id is not applicable to provider_native sources."), - { - code: "invalid_argument", - } - ); - err.param = - typeof requestParams.connection_id === "string" && requestParams.connection_id - ? "connection_id" - : "connector_instance_id"; - throw err; - } - return { - bindings: [ - { - connectorId, - connectorInstanceId: - storageBinding?.connector_instance_id || - makeDefaultAccountConnectorInstanceId(OWNER_AUTH_DEFAULT_SUBJECT_ID, connectorId), - displayName: null, - }, - ], - requestConnectionId: null, - warnings: [], - }; + const streamGrant = grant.streams.find((s) => s.name === streamName); + let authorizedInstanceIds: string[] | null | undefined; + if (ownerRead) { + authorizedInstanceIds = null; + } else if (streamName) { + authorizedInstanceIds = streamGrant?.instance_ids; + } else { + authorizedInstanceIds = [...new Set(grant.streams.flatMap((entry) => entry.instance_ids || []))]; } - const connectorInstanceIdHint = storageBinding?.connector_instance_id || undefined; - const streamGrant = grant.streams.find((s) => s.name === streamName); - const grantStreamConnectionId = streamGrant?.connection_id || undefined; + const instanceIds = ownerRead + ? (await listActiveBindingsForGrant({ connectorId, ownerSubjectId })).map((binding) => binding.connectorInstanceId) + : authorizedInstanceIds || []; return await Reflect.apply(resolveRequestBindings, undefined, [ { + authorizedInstanceIds: instanceIds, connectorId, connectorInstanceIdHint, - grantStreamConnectionId, ownerSubjectId, requestParams, }, @@ -7094,11 +7035,11 @@ export function getDatasetSummaryStreamRecordTimeBounds(connectorId: string, str if (isPostgresStorageBackend()) { return { earliest: null, latest: null }; } - if (!SAFE_JSON_FIELD_NAME.test(consentTimeField || "")) { - throw new Error("unsafe consent_time_field for dataset summary stream reconciliation"); + if (!consentTimeField) { + throw new Error("consent_time_field is required for dataset summary stream reconciliation"); } - const jsonPath = `$.${consentTimeField}`; + const jsonPath = jsonPathForTopLevelField(consentTimeField); const result = getOne<DatasetTimeBoundsRow>(referenceQueries.recordsDatasetGetStreamTimeBounds, [ jsonPath, jsonPath, @@ -7135,12 +7076,12 @@ function recordTimeBoundsForManifest( for (const stream of manifest.streams) { const field = stream?.consent_time_field; const streamName = stream?.name; - if (typeof field !== "string" || !field || typeof streamName !== "string" || !SAFE_JSON_FIELD_NAME.test(field)) { + if (typeof field !== "string" || !field || typeof streamName !== "string") { continue; } const result = getOne<DatasetTimeBoundsRow>(referenceQueries.recordsDatasetGetStreamTimeBounds, [ - `$.${field}`, - `$.${field}`, + jsonPathForTopLevelField(field), + jsonPathForTopLevelField(field), connectorId, streamName, ]); @@ -7226,7 +7167,7 @@ function getManifestConsentTimeField(connectorId: string, streamName: string): s if (typeof field !== "string" || !field) { return null; } - return SAFE_JSON_FIELD_NAME.test(field) ? field : null; + return field; } // Below this, a numeric timestamp is treated as Unix SECONDS; at or above it, as diff --git a/reference-implementation/server/ref-control.ts b/reference-implementation/server/ref-control.ts index ca1addb73..247d514b1 100644 --- a/reference-implementation/server/ref-control.ts +++ b/reference-implementation/server/ref-control.ts @@ -17,6 +17,11 @@ import type { BrowserSurface, BrowserSurfaceLease } from "@opendatalabs/remote-s import { allowUnboundedReadAcknowledged, iterateDynamicSqlAcknowledged, referenceQueries } from "../lib/db.ts"; import { isNullish } from "../lib/nullish.ts"; import type { SpineSummary } from "../lib/spine.ts"; +import type { RefApprovalDetail } from "../operations/ref-approval-detail/index.ts"; +import { + buildLiveConsentApprovalDetail, + buildOwnerDeviceApprovalDetail, +} from "../operations/ref-approval-detail/index.ts"; import { CONNECTOR_SUMMARY_PAGE_LIMIT_MAX, type ConnectorIdentityPageBoundary, @@ -83,7 +88,12 @@ import { import type { RenderedVerdict, ScheduleEvidence } from "../runtime/rendered-verdict.ts"; import { SOURCE_PRESSURE_GAP_REASONS } from "../runtime/scheduler-source-pressure-cooldown.ts"; import { pickMostUrgentAttention } from "./attention-urgency.ts"; -import { getConnectorManifest } from "./auth.ts"; +import { + getConnectorManifest, + getOwnerDeviceAuthRowByApprovalId, + getPendingConsent, + getPendingConsentRowByApprovalId, +} from "./auth.ts"; import { type EnrollmentShellLike, retireExpiredBrowserEnrollmentShells, @@ -945,6 +955,8 @@ interface PendingOwnerDeviceRow { interface ConsentRequestEnvelope { client?: { client_id?: string }; + entries?: unknown[]; + request_kind?: string; selection?: { access_mode?: string; purpose_code?: string; @@ -961,6 +973,8 @@ interface SourcePreview { interface ConsentApproval { readonly approval_id: string; + /** The console must send batch rows through hosted source review. */ + readonly batch: boolean; readonly client_id: string | null; readonly created_at: string; readonly grant_preview: { @@ -6786,6 +6800,7 @@ function buildConsentApproval(row: PendingConsentRow): ConsentApproval | null { const source = sourcePreviewFromConsentRequest(request); return { approval_id: row.approval_id, + batch: request.request_kind === "pdpp_selection_request_batch" && Array.isArray(request.entries), client_id: request.client?.client_id || null, created_at: row.created_at, grant_preview: { @@ -6893,16 +6908,30 @@ export function listPendingApprovals(): Promise<Approval[]> { return Promise.resolve(approvals); } +/** + * Reads one live approval through its opaque public handle. This projection is + * deliberately allowlisted: no caller receives the persisted request blob or + * the device-flow credentials it contains. + */ +export async function getPendingApprovalDetail(approvalId: string): Promise<RefApprovalDetail | null> { + const consent = await getPendingConsentRowByApprovalId(approvalId); + if (consent) { + return buildLiveConsentApprovalDetail(consent, await getPendingConsent(consent.device_code)); + } + return buildOwnerDeviceApprovalDetail(await getOwnerDeviceAuthRowByApprovalId(approvalId)); +} + // ─── Records timeline ─────────────────────────────────────────────────────── -const SAFE_JSON_FIELD_RE = /^[A-Za-z_][A-Za-z_0-9]*$/; const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; -function safeJsonPathExpr(field: string, label: string): string { - if (typeof field !== "string" || !SAFE_JSON_FIELD_RE.test(field)) { - throw new Error(`[ref-control] Unsafe JSON field ${label}: ${JSON.stringify(field)}`); +function sqliteTopLevelJsonPath(field: string, label: string): string { + if (typeof field !== "string" || field.length === 0) { + throw new Error(`[ref-control] JSON field ${label} must be a non-empty string: ${JSON.stringify(field)}`); } - return `json_extract(record_json, '$.${field}')`; + // Keep the field in a bound SQLite parameter. JSON.stringify makes the + // segment literal, so dots and quotes remain part of one top-level key. + return `$.${JSON.stringify(field)}`; } /** @@ -6987,14 +7016,15 @@ function buildTimelineSql({ since: string | null; timestampMode: "emitted" | "native"; until: string | null; -}): { sql: string; binds: (number | string)[]; timestampExpr: string } { +}): { sql: string; binds: (number | string)[]; semanticFieldPath: string | null; timestampExpr: string } { // Keep this dynamic SQL inline: optional time-window predicates, native // timestamp JSON fields, and caller-selected order direction change the // statement shape in ways that are easier to audit beside the validation. const semanticField = timestampMode === "native" ? manifestStream?.consent_time_field || manifestStream?.cursor_field || null : null; - const timestampExpr = semanticField - ? `COALESCE(NULLIF(${safeJsonPathExpr(semanticField, "semantic_time_field")}, ''), emitted_at)` + const semanticFieldPath = semanticField ? sqliteTopLevelJsonPath(semanticField, "semantic_time_field") : null; + const timestampExpr = semanticFieldPath + ? "COALESCE(NULLIF(json_extract(record_json, (SELECT path FROM semantic_path)), ''), emitted_at)" : "emitted_at"; const where: string[] = ["connector_id = ?", "stream = ?", "deleted = 0"]; @@ -7016,13 +7046,14 @@ function buildTimelineSql({ } const sql = ` + ${semanticFieldPath ? "WITH semantic_path(path) AS (VALUES (?))" : ""} SELECT connector_id, stream, record_key, record_json, emitted_at, version FROM records WHERE ${where.join(" AND ")} ORDER BY ${timestampExpr} ${orderDir}, emitted_at ${orderDir}, record_key ${orderDir} LIMIT ? `; - return { binds, sql, timestampExpr }; + return { binds, semanticFieldPath, sql, timestampExpr }; } function comparePrimaryDesc(order: "asc" | "desc", left: TimelineEntry, right: TimelineEntry): number { @@ -7109,7 +7140,7 @@ async function collectPairEntries( const manifest = (await getConnectorManifest(pair.connectorId)) as ConnectorManifest | null; const manifestStream = manifest?.streams?.find((item) => item.name === pair.stream) ?? null; - const { sql, binds } = buildTimelineSql({ + const { sql, binds, semanticFieldPath } = buildTimelineSql({ manifestStream, orderDir: opts.orderDir, since: opts.since, @@ -7123,12 +7154,14 @@ async function collectPairEntries( // values, so the artifact registry cannot validate this SQL up front. The // statement embeds a trailing LIMIT ? bound by perPairLimit, which the caller // derives from the request's `limit` field. - for (const row of iterateDynamicSqlAcknowledged<TimelineQueryRow>(sql, [ + const queryBinds = [ + ...(semanticFieldPath ? [semanticFieldPath] : []), pair.connectorId, pair.stream, ...binds, opts.perPairLimit, - ])) { + ]; + for (const row of iterateDynamicSqlAcknowledged<TimelineQueryRow>(sql, queryBinds)) { const entry = buildTimelineEntry(row, manifestStream, opts.timestampMode); if (!entry) { continue; diff --git a/reference-implementation/server/routes/as-agent-connect.ts b/reference-implementation/server/routes/as-agent-connect.ts index 3a8786abe..eebb9987c 100644 --- a/reference-implementation/server/routes/as-agent-connect.ts +++ b/reference-implementation/server/routes/as-agent-connect.ts @@ -19,8 +19,13 @@ // The in-progress attempt state lives in an `AgentConnectAttemptStore` created // by `createAgentConnectAttemptStore`. The store is instantiated once in // `buildAsApp` and passed to both route adapters AND to the consent -// approve/deny handlers so all three share the same Map. +// approve/deny handlers so all three share the same durable rows. +import { createHash, timingSafeEqual } from "node:crypto"; +import { exec, getMany, getOne, referenceQueries } from "../../lib/db.ts"; +import { parsePendingConsentRequestUri } from "../auth.ts"; +import { applyCredentialResponseNoStoreHeaders } from "../credential-response-cache.ts"; +import { isPostgresStorageBackend, postgresQuery, withPostgresTransaction } from "../postgres-storage.ts"; import type { PdppErrorFn, RouteArg } from "./_route-contract.ts"; // ─── Attempt store ─────────────────────────────────────────────────────────── @@ -36,7 +41,9 @@ export interface AgentConnectAttempt { readonly id: string; readonly interval: number; readonly pollingCode: string; + readonly pollingCodeHash?: string; readonly requestUri: string; + responseJson?: string | null; status: "pending" | "approved" | "denied" | "expired"; token?: string; readonly tokenUrl: string; @@ -49,93 +56,719 @@ export interface AgentConnectAttemptStore { * deny handler with `status: 'denied'`. */ complete: ( - requestUri: string, + requestUri: string | null | undefined, outcome: | { status: "approved"; token: string; grant: Record<string, unknown>; grantId?: string | null } | { status: "denied" | "expired" } - ) => void; - /** Create and register a new pending attempt. Returns the stored attempt. */ + ) => Promise<void>; + /** Create and register a new pending attempt, if consent remains pending. */ create: (opts: { id: string; + now: number; pollingCode: string; requestUri: string; clientId: string | null; expiresAt: number; approvalUrl: string; tokenUrl: string; - }) => AgentConnectAttempt; + }) => Promise<AgentConnectAttempt | undefined>; /** Remove an attempt by id. */ - delete: (id: string) => void; + delete: (id: string) => Promise<void>; /** * Shorthand for `complete(requestUri, { status })` for non-approval outcomes. * Called by the consent deny handler. */ - fail: (requestUri: string, status: "denied" | "expired") => void; + fail: (requestUri: string | null | undefined, status: "denied" | "expired") => Promise<void>; /** Look up an attempt by id. */ - get: (id: string) => AgentConnectAttempt | undefined; + get: (id: string) => Promise<AgentConnectAttempt | undefined>; /** Evict expired/completed attempts (call before creating new ones). */ - prune: (now?: number) => void; + prune: (now?: number) => Promise<void>; + redeem: ( + id: string, + pollingCode: string, + now?: number + ) => Promise< + | { outcome: "missing" } + | { outcome: "pending"; interval: number } + | { outcome: "failed"; status: "denied" | "expired" } + | { outcome: "approved"; body: Record<string, unknown>; replay: boolean } + >; +} + +let completeFailureForTest: (() => void) | null = null; +let cleanupAfterMissForTest: (() => void | Promise<void>) | null = null; +let cleanupBeforeExpireForTest: (() => void | Promise<void>) | null = null; +let createBeforePersistForTest: (() => void | Promise<void>) | null = null; +let completeBeforeMarkForTest: (() => void | Promise<void>) | null = null; + +export function __setAgentConnectCompleteFailureForTest(fn: (() => void) | null): void { + completeFailureForTest = fn; +} + +export function __setAgentConnectCleanupAfterMissForTest(fn: (() => void | Promise<void>) | null): void { + cleanupAfterMissForTest = fn; +} + +export function __setAgentConnectCleanupBeforeExpireForTest(fn: (() => void | Promise<void>) | null): void { + cleanupBeforeExpireForTest = fn; +} + +export function __setAgentConnectCreateBeforePersistForTest(fn: (() => void | Promise<void>) | null): void { + createBeforePersistForTest = fn; +} + +export function __setAgentConnectCompleteBeforeMarkForTest(fn: (() => void | Promise<void>) | null): void { + completeBeforeMarkForTest = fn; +} + +interface RecoveredApprovedConsent { + grant_id?: string | null; + grant_json?: unknown; + package_json?: unknown; + token_id?: string | null; +} + +function objectFromStoredJson(value: unknown): Record<string, unknown> | null { + if (typeof value === "string") { + return JSON.parse(value) as Record<string, unknown>; + } + if (value && typeof value === "object") { + return value as Record<string, unknown>; + } + return null; +} + +function grantFromRecoveredConsent(recovered: RecoveredApprovedConsent): Record<string, unknown> | null { + return objectFromStoredJson(recovered.grant_json) ?? objectFromStoredJson(recovered.package_json); } export function createAgentConnectAttemptStore(): AgentConnectAttemptStore { - const attempts = new Map<string, AgentConnectAttempt>(); + const hashPollingCode = (code: string) => createHash("sha256").update(code, "utf8").digest("base64url"); + const pollingCodeMatches = (hash: string, code: string) => { + const expected = Buffer.from(hash); + const actual = Buffer.from(hashPollingCode(code)); + return expected.length === actual.length && timingSafeEqual(expected, actual); + }; + const rowToAttempt = (row: Record<string, unknown>): AgentConnectAttempt => { + const grantJson = row.grant_json; + const attempt: AgentConnectAttempt = { + approvalUrl: row.approval_url as string, + clientId: (row.client_id as string | null | undefined) ?? null, + createdAt: row.created_at as string, + expiresAt: Number(row.expires_at_ms), + grantId: (row.grant_id as string | null | undefined) ?? null, + id: row.id as string, + interval: Number(row.interval_seconds ?? 2), + pollingCode: "", + pollingCodeHash: row.polling_code_hash as string, + requestUri: row.request_uri as string, + responseJson: (row.response_json as string | null | undefined) ?? null, + status: row.status as AgentConnectAttempt["status"], + tokenUrl: row.token_url as string, + }; + if (typeof row.completed_at === "string") { + attempt.completedAt = row.completed_at; + } + if (typeof grantJson === "string") { + attempt.grant = JSON.parse(grantJson) as Record<string, unknown>; + } else if (grantJson && typeof grantJson === "object") { + attempt.grant = grantJson as Record<string, unknown>; + } + if (typeof row.token === "string") { + attempt.token = row.token; + } + return attempt; + }; + const getRow = async (id: string): Promise<AgentConnectAttempt | undefined> => { + if (isPostgresStorageBackend()) { + const result = await postgresQuery("SELECT * FROM agent_connect_attempts WHERE id = $1", [id]); + const [row] = result.rows; + return row ? rowToAttempt(row) : undefined; + } + const row = getOne<Record<string, unknown>>(referenceQueries.authAgentConnectAttemptsGetById, [id]); + return row ? rowToAttempt(row) : undefined; + }; + const tokenIsActive = async (tokenId: string | undefined): Promise<boolean> => { + if (!tokenId) { + return false; + } + if (isPostgresStorageBackend()) { + const result = await postgresQuery<{ ok: boolean }>( + `SELECT EXISTS( + SELECT 1 + FROM tokens + WHERE token_id = $1 + AND revoked = FALSE + AND (expires_at IS NULL OR expires_at > $2) + ) AS ok`, + [tokenId, new Date().toISOString()] + ); + return Boolean(result.rows[0]?.ok); + } + const row = getOne<{ ok: number }>(referenceQueries.authAgentConnectAttemptsTokenActive, [ + tokenId, + new Date().toISOString(), + ]); + return Boolean(row?.ok); + }; + const revokeTokenIfNoLiveSibling = async ( + tokenId: string | undefined, + requestUri: string, + excludingId: string, + now: number + ): Promise<void> => { + if (!tokenId) { + return; + } + if (isPostgresStorageBackend()) { + await postgresQuery( + `UPDATE tokens + SET revoked = TRUE + WHERE token_id = $1 + AND revoked = FALSE + AND NOT EXISTS ( + SELECT 1 + FROM agent_connect_attempts + WHERE request_uri = $2 + AND id != $3 + AND status IN ('pending', 'approved') + AND expires_at_ms > $4 + )`, + [tokenId, requestUri, excludingId, now] + ); + return; + } + exec(referenceQueries.authAgentConnectAttemptsRevokeTokenIfNoLiveSibling, [tokenId, requestUri, excludingId, now]); + }; + const deleteAttempt = async (id: string): Promise<void> => { + if (isPostgresStorageBackend()) { + await postgresQuery("DELETE FROM agent_connect_attempts WHERE id = $1", [id]); + return; + } + exec(referenceQueries.authAgentConnectAttemptsDeleteById, [id]); + }; + const markAttemptApproved = async ( + requestUri: string, + token: string, + grant: Record<string, unknown>, + grantId: string | null + ): Promise<void> => { + if (completeBeforeMarkForTest) { + await completeBeforeMarkForTest(); + } + const completedAt = new Date().toISOString(); + const storedGrantId = (grant.grant_id as string | null | undefined) ?? grantId; + if (isPostgresStorageBackend()) { + const result = await postgresQuery( + `UPDATE agent_connect_attempts + SET status = 'approved', completed_at = $2, token = $3, grant_json = $4::jsonb, grant_id = $5 + WHERE request_uri = $1 AND status = 'pending'`, + [requestUri, completedAt, token, JSON.stringify(grant), storedGrantId] + ); + if (result.rowCount === 0) { + const expired = await postgresQuery<{ id: string }>( + "SELECT id FROM agent_connect_attempts WHERE request_uri = $1 AND status = 'expired' LIMIT 1", + [requestUri] + ); + const expiredId = expired.rows[0]?.id; + if (expiredId) { + await revokeTokenIfNoLiveSibling(token, requestUri, expiredId, Date.now()); + } + } + return; + } + const result = exec(referenceQueries.authAgentConnectAttemptsMarkApproved, [ + completedAt, + token, + JSON.stringify(grant), + storedGrantId, + requestUri, + ]); + if (result.changes === 0) { + const expired = getOne<{ id: string }>(referenceQueries.authAgentConnectAttemptsGetExpiredByRequestUri, [ + requestUri, + ]); + if (expired) { + await revokeTokenIfNoLiveSibling(token, requestUri, expired.id, Date.now()); + } + } + }; + const getRecoveredApprovedConsent = async (requestUri: string): Promise<RecoveredApprovedConsent | undefined> => { + const deviceCode = parsePendingConsentRequestUri(requestUri); + if (!deviceCode) { + return; + } + if (isPostgresStorageBackend()) { + const result = await postgresQuery( + `SELECT pc.grant_id, pc.token_id, g.grant_json, gp.package_json + FROM pending_consents pc + LEFT JOIN grants g ON g.grant_id = pc.grant_id + LEFT JOIN grant_packages gp ON gp.package_id = pc.grant_id + WHERE pc.device_code = $1 + AND pc.status = 'approved' + AND pc.token_id IS NOT NULL + AND pc.grant_id IS NOT NULL + LIMIT 1`, + [deviceCode] + ); + const [recovered] = result.rows as RecoveredApprovedConsent[]; + return recovered; + } + return ( + getOne<RecoveredApprovedConsent>(referenceQueries.authAgentConnectAttemptsRecoverApproved, [deviceCode]) ?? + undefined + ); + }; + const getPendingConsentStatus = async (requestUri: string): Promise<string | null> => { + const deviceCode = parsePendingConsentRequestUri(requestUri); + if (!deviceCode) { + return null; + } + if (isPostgresStorageBackend()) { + const result = await postgresQuery<{ status?: string | null }>( + "SELECT status FROM pending_consents WHERE device_code = $1", + [deviceCode] + ); + return result.rows[0]?.status ?? null; + } + return ( + getOne<{ status?: string | null }>(referenceQueries.authPendingConsentsGetByDeviceCode, [deviceCode])?.status ?? + null + ); + }; + const markAttemptDenied = async (requestUri: string): Promise<void> => { + const completedAt = new Date().toISOString(); + if (isPostgresStorageBackend()) { + await postgresQuery( + `UPDATE agent_connect_attempts + SET status = 'denied', completed_at = $2 + WHERE request_uri = $1 AND status = 'pending'`, + [requestUri, completedAt] + ); + return; + } + exec(referenceQueries.authAgentConnectAttemptsMarkFailed, ["denied", completedAt, requestUri]); + }; + const recoverApprovedAttempt = async (attempt: AgentConnectAttempt, now: number): Promise<AgentConnectAttempt> => { + if (attempt.status !== "pending" || attempt.expiresAt <= now) { + return attempt; + } + const recovered = await getRecoveredApprovedConsent(attempt.requestUri); + if (!recovered?.token_id) { + return attempt; + } + const grant = grantFromRecoveredConsent(recovered); + if (!(grant && (await tokenIsActive(recovered.token_id)))) { + return attempt; + } + await markAttemptApproved(attempt.requestUri, recovered.token_id, grant, recovered.grant_id ?? null); + return (await getRow(attempt.id)) ?? attempt; + }; + const reconcilePendingConsent = async (attempt: AgentConnectAttempt, now: number): Promise<AgentConnectAttempt> => { + if (attempt.status !== "pending") { + return attempt; + } + const consentStatus = await getPendingConsentStatus(attempt.requestUri); + if (consentStatus === "approved") { + return recoverApprovedAttempt(attempt, now); + } + if (consentStatus === "denied") { + await markAttemptDenied(attempt.requestUri); + } else if (consentStatus === "expired") { + await markAttemptExpired(attempt.id); + } + return (await getRow(attempt.id)) ?? attempt; + }; + const markAttemptExpired = async (id: string): Promise<boolean> => { + const completedAt = new Date().toISOString(); + if (isPostgresStorageBackend()) { + const result = await postgresQuery( + `UPDATE agent_connect_attempts + SET status = 'expired', completed_at = $2 + WHERE id = $1 + AND status IN ('pending', 'expired')`, + [id, completedAt] + ); + return (result.rowCount ?? 0) > 0; + } + const result = exec(referenceQueries.authAgentConnectAttemptsMarkExpiredById, [completedAt, id]); + return result.changes > 0; + }; + const cleanupExpiredAttempt = async (attempt: AgentConnectAttempt, now: number): Promise<void> => { + const { requestUri, status } = attempt; + if (!(attempt.token || status === "pending" || status === "expired")) { + return; + } + let recovered = await getRecoveredApprovedConsent(requestUri); + if (!(attempt.token || recovered?.token_id)) { + if (cleanupAfterMissForTest) { + await cleanupAfterMissForTest(); + } + recovered = await getRecoveredApprovedConsent(requestUri); + } + if (cleanupBeforeExpireForTest) { + await cleanupBeforeExpireForTest(); + } + await markAttemptExpired(attempt.id); + const latest = await getRow(attempt.id); + const token = + latest?.token || + attempt.token || + recovered?.token_id || + (await getRecoveredApprovedConsent(requestUri))?.token_id || + undefined; + await revokeTokenIfNoLiveSibling(token, requestUri, attempt.id, now); + }; + const deleteExpiredTombstoneIfConsentTerminal = ( + attempt: AgentConnectAttempt, + now: number + ): Promise<boolean> | boolean => { + const deviceCode = parsePendingConsentRequestUri(attempt.requestUri); + if (!deviceCode) { + if (isPostgresStorageBackend()) { + return postgresQuery("DELETE FROM agent_connect_attempts WHERE id = $1 AND status = 'expired'", [ + attempt.id, + ]).then((deletedRow) => (deletedRow.rowCount ?? 0) > 0); + } + return exec(referenceQueries.authAgentConnectAttemptsDeleteExpiredById, [attempt.id]).changes > 0; + } + const nowIso = new Date(now).toISOString(); + if (isPostgresStorageBackend()) { + return withPostgresTransaction(async (client) => { + await client.query( + `UPDATE pending_consents + SET status = 'expired' + WHERE device_code = $1 + AND status = 'pending' + AND expires_at <= $2`, + [deviceCode, nowIso] + ); + const result = await client.query( + `DELETE FROM agent_connect_attempts + WHERE id = $1 + AND status = 'expired' + AND NOT EXISTS ( + SELECT 1 + FROM pending_consents + WHERE device_code = $2 + AND status IN ('pending', 'approving', 'approved') + )`, + [attempt.id, deviceCode] + ); + return (result.rowCount ?? 0) > 0; + }); + } + exec(referenceQueries.authPendingConsentsMarkExpiredIfDue, [deviceCode, nowIso]); + const result = exec(referenceQueries.authAgentConnectAttemptsDeleteExpiredIfConsentTerminal, [ + attempt.id, + deviceCode, + ]); + return result.changes > 0; + }; + const cleanupExpiredPendingAttempts = async (now: number): Promise<void> => { + for (;;) { + let attempts: AgentConnectAttempt[]; + if (isPostgresStorageBackend()) { + // biome-ignore lint/performance/noAwaitInLoops: each batch observes the prior batch's CAS tombstones. + const result = await postgresQuery( + `SELECT * + FROM agent_connect_attempts + WHERE status = 'pending' + AND expires_at_ms <= $1 + ORDER BY id + LIMIT 1000`, + [now] + ); + attempts = result.rows.map((row) => rowToAttempt(row as Record<string, unknown>)); + } else { + const page = getMany<Record<string, unknown>>( + referenceQueries.authAgentConnectAttemptsListExpiredPending, + [now], + { limit: 1000 } + ); + attempts = page.rows.map(rowToAttempt); + } + if (attempts.length === 0) { + return; + } + for (const attempt of attempts) { + // biome-ignore lint/performance/noAwaitInLoops: cleanup is intentionally ordered so each CAS/revoke completes before the next batch row is observed. + await cleanupExpiredAttempt(attempt, now); + } + } + }; + const pruneExpiredHistoricTombstones = async (now: number): Promise<void> => { + for (;;) { + let deleted: number; + if (isPostgresStorageBackend()) { + // biome-ignore lint/performance/noAwaitInLoops: each bounded delete must finish before observing whether another page remains. + const result = await postgresQuery( + `WITH historic AS ( + SELECT id + FROM agent_connect_attempts + WHERE status = 'expired' + AND expires_at_ms <= $1 + AND request_uri NOT LIKE 'urn:pdpp:pending-consent:%' + AND token IS NULL + ORDER BY id + LIMIT 1000 + ) + DELETE FROM agent_connect_attempts AS attempts + USING historic + WHERE attempts.id = historic.id`, + [now] + ); + deleted = result.rowCount ?? 0; + } else { + deleted = exec(referenceQueries.authAgentConnectAttemptsDeleteExpiredHistoricPage, [now, 1000]).changes; + } + if (deleted < 1000) { + return; + } + } + }; + const pruneExpiredTombstones = async (now: number): Promise<void> => { + for (;;) { + let attempts: AgentConnectAttempt[]; + if (isPostgresStorageBackend()) { + // biome-ignore lint/performance/noAwaitInLoops: each page observes prior safe deletes. + const result = await postgresQuery( + `SELECT attempts.* + FROM agent_connect_attempts AS attempts + LEFT JOIN pending_consents AS consent + ON attempts.request_uri = 'urn:pdpp:pending-consent:' || consent.device_code + WHERE attempts.status = 'expired' + AND attempts.expires_at_ms <= $1 + AND ( + consent.device_code IS NULL + OR consent.status IN ('denied', 'expired') + OR (consent.status = 'pending' AND consent.expires_at <= $2) + ) + ORDER BY attempts.id + LIMIT 1000`, + [now, new Date(now).toISOString()] + ); + attempts = result.rows.map((row) => rowToAttempt(row as Record<string, unknown>)); + } else { + const page = getMany<Record<string, unknown>>( + referenceQueries.authAgentConnectAttemptsListExpiredTombstones, + [now, new Date(now).toISOString()], + { limit: 1000 } + ); + attempts = page.rows.map(rowToAttempt); + } + if (attempts.length === 0) { + return; + } + let deleted = 0; + for (const attempt of attempts) { + // The candidate query and delete accept the same canonical and historic + // request-URI shapes, so each full page must make durable progress. + // biome-ignore lint/performance/noAwaitInLoops: each durable delete must complete before the next page is inspected. + const tombstoneDeleted = await deleteExpiredTombstoneIfConsentTerminal(attempt, now); + if (tombstoneDeleted) { + await cleanupExpiredAttempt(attempt, now); + deleted += 1; + } + } + if (deleted === 0 || attempts.length < 1000) { + return; + } + } + }; return { - complete(requestUri, outcome): void { - for (const attempt of attempts.values()) { - if (attempt.requestUri !== requestUri || attempt.status !== "pending") { - continue; - } - attempt.status = outcome.status; - attempt.completedAt = new Date().toISOString(); + async complete(requestUri, outcome): Promise<void> { + if (!requestUri) { + return; + } + completeFailureForTest?.(); + const completedAt = new Date().toISOString(); + if (isPostgresStorageBackend()) { if (outcome.status === "approved") { - attempt.token = outcome.token; - attempt.grant = outcome.grant; - attempt.grantId = (outcome.grant.grant_id as string | null | undefined) ?? outcome.grantId ?? null; + await markAttemptApproved(requestUri, outcome.token, outcome.grant, outcome.grantId ?? null); + return; } + await postgresQuery( + `UPDATE agent_connect_attempts + SET status = $2, completed_at = $3 + WHERE request_uri = $1 AND status = 'pending'`, + [requestUri, outcome.status, completedAt] + ); + return; } + if (outcome.status === "approved") { + await markAttemptApproved(requestUri, outcome.token, outcome.grant, outcome.grantId ?? null); + return; + } + exec(referenceQueries.authAgentConnectAttemptsMarkFailed, [outcome.status, completedAt, requestUri]); }, - create(opts): AgentConnectAttempt { + async create(opts): Promise<AgentConnectAttempt | undefined> { + const createdAt = new Date().toISOString(); + const pollingCodeHash = hashPollingCode(opts.pollingCode); const attempt: AgentConnectAttempt = { approvalUrl: opts.approvalUrl, clientId: opts.clientId, - createdAt: new Date().toISOString(), + createdAt, expiresAt: opts.expiresAt, id: opts.id, interval: 2, pollingCode: opts.pollingCode, + pollingCodeHash, requestUri: opts.requestUri, status: "pending", tokenUrl: opts.tokenUrl, }; - attempts.set(opts.id, attempt); + const deviceCode = parsePendingConsentRequestUri(opts.requestUri); + if (!deviceCode) { + return; + } + if (createBeforePersistForTest) { + await createBeforePersistForTest(); + } + if (isPostgresStorageBackend()) { + return withPostgresTransaction(async (client) => { + const pending = await client.query( + `SELECT 1 + FROM pending_consents + WHERE device_code = $1 + AND status = 'pending' + AND expires_at > $2 + FOR UPDATE`, + [deviceCode, new Date(opts.now).toISOString()] + ); + if (pending.rowCount !== 1) { + return; + } + await client.query( + `INSERT INTO agent_connect_attempts( + id, request_uri, client_id, polling_code_hash, status, approval_url, token_url, + interval_seconds, created_at, expires_at_ms + ) VALUES($1, $2, $3, $4, 'pending', $5, $6, 2, $7, $8)`, + [ + opts.id, + opts.requestUri, + opts.clientId, + pollingCodeHash, + opts.approvalUrl, + opts.tokenUrl, + createdAt, + opts.expiresAt, + ] + ); + return attempt; + }); + } + const result = exec(referenceQueries.authAgentConnectAttemptsInsertIfConsentPending, [ + opts.id, + opts.requestUri, + opts.clientId, + pollingCodeHash, + opts.approvalUrl, + opts.tokenUrl, + createdAt, + opts.expiresAt, + deviceCode, + new Date(opts.now).toISOString(), + ]); + if (result.changes !== 1) { + return; + } return attempt; }, - delete(id): void { - attempts.delete(id); + async delete(id): Promise<void> { + await deleteAttempt(id); }, - fail(requestUri, status): void { - for (const attempt of attempts.values()) { - if (attempt.requestUri !== requestUri || attempt.status !== "pending") { - continue; - } - attempt.status = status; - attempt.completedAt = new Date().toISOString(); - } + async fail(requestUri, status): Promise<void> { + await this.complete(requestUri, { status }); }, - get(id): AgentConnectAttempt | undefined { - return attempts.get(id); + get(id): Promise<AgentConnectAttempt | undefined> { + return getRow(id); }, - prune(now = Date.now()): void { - for (const [id, attempt] of attempts) { - if (attempt.status !== "pending" || attempt.expiresAt <= now) { - attempts.delete(id); - } + async prune(now = Date.now()): Promise<void> { + await cleanupExpiredPendingAttempts(now); + await pruneExpiredHistoricTombstones(now); + await pruneExpiredTombstones(now); + if (isPostgresStorageBackend()) { + await postgresQuery( + `DELETE FROM agent_connect_attempts + WHERE status = 'denied' + OR (status = 'approved' AND response_json IS NOT NULL AND expires_at_ms <= $1)`, + [now] + ); + return; + } + exec(referenceQueries.authAgentConnectAttemptsPrune, [now]); + }, + + async redeem(id, pollingCode, now = Date.now()) { + let attempt = await getRow(id); + if (!(attempt?.pollingCodeHash && pollingCodeMatches(attempt.pollingCodeHash, pollingCode))) { + return { outcome: "missing" }; + } + attempt = await reconcilePendingConsent(attempt, now); + if (attempt.expiresAt <= now) { + await cleanupExpiredAttempt(attempt, now); + return { outcome: "failed", status: "expired" }; + } + if (attempt.status === "pending") { + return { interval: attempt.interval, outcome: "pending" }; + } + if (attempt.status !== "approved") { + await this.delete(attempt.id); + return { outcome: "failed", status: attempt.status }; + } + if (!(await tokenIsActive(attempt.token))) { + await this.delete(attempt.id); + return { outcome: "missing" }; + } + if (attempt.responseJson) { + return { body: JSON.parse(attempt.responseJson) as Record<string, unknown>, outcome: "approved", replay: true }; + } + const body = { + access_token: attempt.token, + grant: attempt.grant, + grant_id: attempt.grantId, + token_type: "Bearer", + }; + const responseJson = JSON.stringify(body); + if (isPostgresStorageBackend()) { + return withPostgresTransaction(async (client) => { + const update = await client.query( + `UPDATE agent_connect_attempts + SET response_json = $2 + WHERE id = $1 AND status = 'approved' AND response_json IS NULL + RETURNING response_json`, + [id, responseJson] + ); + if (update.rowCount === 1) { + return { body, outcome: "approved", replay: false }; + } + const reread = await client.query("SELECT response_json FROM agent_connect_attempts WHERE id = $1", [id]); + const [row] = reread.rows; + if (typeof row?.response_json === "string") { + return { + body: JSON.parse(row.response_json) as Record<string, unknown>, + outcome: "approved", + replay: true, + }; + } + return { outcome: "missing" }; + }); } + const result = exec(referenceQueries.authAgentConnectAttemptsSetResponseJson, [responseJson, id]); + if (result.changes === 1) { + return { body, outcome: "approved", replay: false }; + } + const replay = await getRow(id); + if (replay?.responseJson) { + return { body: JSON.parse(replay.responseJson) as Record<string, unknown>, outcome: "approved", replay: true }; + } + return { outcome: "missing" }; }, }; } @@ -174,6 +807,7 @@ interface RouteRequest { interface RouteResponse { json: (body: unknown) => unknown; + setHeader: (name: string, value: string) => unknown; status: (code: number) => RouteResponse; } @@ -290,20 +924,25 @@ export function mountAsAgentConnect(app: AppLike, ctx: MountAsAgentConnectContex } const now = ctx.now(); - ctx.agentConnectAttemptStore.prune(now); + await ctx.agentConnectAttemptStore.prune(now); const id = ctx.generateAttemptId(); const pollingCode = ctx.generatePollingCode(); - const attempt = ctx.agentConnectAttemptStore.create({ + const attempt = await ctx.agentConnectAttemptStore.create({ approvalUrl: ctx.buildApprovalUrl(baseUrl, requestUri), clientId: pendingClientId ?? clientId, expiresAt: now + ctx.agentConnectTtlMs, id, + now, pollingCode, requestUri, tokenUrl: ctx.buildTokenUrl(baseUrl, id), }); + if (!attempt) { + return ctx.pdppError(res, 400, "expired_token", "Pending grant request is unknown or expired"); + } + applyCredentialResponseNoStoreHeaders(res); return res.status(201).json({ ...publicAttemptEnvelope(attempt, now), polling_code: pollingCode, @@ -324,37 +963,31 @@ export interface MountAsAgentConnectTokenContext { } export function mountAsAgentConnectToken(app: AppLike, ctx: MountAsAgentConnectTokenContext): void { - const handler: RouteHandler = (req, res) => { + const handler: RouteHandler = async (req, res) => { try { const attemptId = req.params.attemptId ?? ""; - const attempt = ctx.agentConnectAttemptStore.get(attemptId); const pollingCode = typeof req.body?.polling_code === "string" ? req.body.polling_code : null; - if (!attempt || pollingCode !== attempt.pollingCode) { + if (!pollingCode) { return ctx.pdppError(res, 401, "invalid_grant", "Unknown agent-connect polling handle"); } - if (attempt.status === "pending" && attempt.expiresAt <= Date.now()) { - attempt.status = "expired"; + const result = await ctx.agentConnectAttemptStore.redeem(attemptId, pollingCode); + if (result.outcome === "missing") { + return ctx.pdppError(res, 401, "invalid_grant", "Unknown agent-connect polling handle"); } - if (attempt.status === "pending") { + if (result.outcome === "pending") { return res.status(202).json({ error: "authorization_pending", error_description: "Owner approval is still pending", - interval: attempt.interval, + interval: result.interval, status: "pending", }); } - if (attempt.status !== "approved") { - const error = buildAgentConnectError(attempt.status); - ctx.agentConnectAttemptStore.delete(attempt.id); - return ctx.pdppError(res, attempt.status === "denied" ? 403 : 400, error.error, error.error_description); + if (result.outcome === "failed") { + const error = buildAgentConnectError(result.status); + return ctx.pdppError(res, result.status === "denied" ? 403 : 400, error.error, error.error_description); } - ctx.agentConnectAttemptStore.delete(attempt.id); - return res.json({ - access_token: attempt.token, - grant: attempt.grant, - grant_id: attempt.grantId, - token_type: "Bearer", - }); + applyCredentialResponseNoStoreHeaders(res); + return res.json(result.body); } catch (err) { return ctx.handleError(res, err); } diff --git a/reference-implementation/server/routes/as-authorize.ts b/reference-implementation/server/routes/as-authorize.ts index 257ad65ca..52f383005 100644 --- a/reference-implementation/server/routes/as-authorize.ts +++ b/reference-implementation/server/routes/as-authorize.ts @@ -35,6 +35,7 @@ import { renderHostedMcpSourceSelection, requireAuthorizeString, requireRegisteredRedirectUri, + resolveHostedMcpSourceDescriptor, validateAuthorizePkce, } from "./as-consent-ui-helpers.ts"; @@ -64,6 +65,11 @@ interface AppLike { post: (path: string, ...args: RouteArg<RouteHandler | MiddlewareHandler>[]) => AppLike; } +const OAUTH_AUTHORIZATION_ERROR_CODES: Readonly<Record<string, string>> = { + "source.authorization_details_invalid": "invalid_authorization_details", + undefined: "invalid_request", +}; + // Shape expected by requireRegisteredRedirectUri (mirrors as-consent-ui-helpers.ts internal type). interface OAuthClient { readonly metadata?: { redirect_uris?: string[] } | null; @@ -217,6 +223,11 @@ async function accumulateSourceEntry( oauthError(res, 400, "invalid_request", `Unknown connector: ${connectorId}`); return "rejected"; } + const source = resolveHostedMcpSourceDescriptor(manifest); + if (!source) { + oauthError(res, 400, "invalid_request", `Connector ${connectorId} has no valid public source identity`); + return "rejected"; + } let matchedBinding: ConsentPickerBinding | null = null; let activeBindingCount = 0; @@ -270,7 +281,8 @@ async function accumulateSourceEntry( connectorId, narrowedStreamNames, packageAccessMode, - pinnedConnectionId + pinnedConnectionId, + source ) ); acc.storageBindings.push({ connector_id: connectorId }); @@ -294,7 +306,7 @@ function resolveNarrowedStreams( streamSelectionsBySource: Map<string, Set<string>> ): string[] | null | "deselected" { const manifestStreamNames = Array.isArray(manifest?.streams) - ? (manifest?.streams?.map((s) => s.name).filter((n): n is string => typeof n === "string") ?? []) + ? manifest.streams.map((s) => s.name).filter((n): n is string => typeof n === "string") : []; if (manifestStreamNames.length === 0) { return null; // (a) @@ -330,7 +342,24 @@ async function initiateGrantAndRedirect( ctx: MountAsAuthorizeContext, req: RouteRequest ): Promise<unknown> { - const details = authorizationDetails || buildHostedMcpAuthorizationDetailsForConnector(selectedConnectorId as string); + let details = authorizationDetails; + if (!details) { + const connectorId = selectedConnectorId as string; + const manifest = await ctx.consentPickerCaps.getConnectorManifest(connectorId).catch(() => null); + if (!manifest) { + return ctx.oauthError(res, 400, "invalid_request", `Unknown connector: ${connectorId}`); + } + const source = resolveHostedMcpSourceDescriptor(manifest); + if (!source) { + return ctx.oauthError( + res, + 400, + "invalid_request", + `Connector ${connectorId} has no valid public source identity` + ); + } + details = buildHostedMcpAuthorizationDetailsForConnector(connectorId, source); + } const explicitBaseUrl = ctx.asPublicUrl || (ctx.ignoreAmbientPublicUrls ? null : (process.env.AS_PUBLIC_URL ?? null)); const output = await ctx.consentStore.initiateGrant( { authorization_details: details, client_id: pkce.clientId }, @@ -616,7 +645,7 @@ export function mountAsAuthorize(app: AppLike, ctx: MountAsAuthorizeContext): vo ); } - return initiateGrantAndRedirect( + return await initiateGrantAndRedirect( res, authorizationDetails, selectedConnectorId, @@ -625,10 +654,11 @@ export function mountAsAuthorize(app: AppLike, ctx: MountAsAuthorizeContext): vo req ); } catch (err) { + const errorCode = (err as { code?: string }).code; return ctx.oauthError( res, 400, - (err as { code?: string }).code || "invalid_request", + OAUTH_AUTHORIZATION_ERROR_CODES[String(errorCode)] ?? String(errorCode), (err as Error).message || "Authorization request rejected" ); } diff --git a/reference-implementation/server/routes/as-consent-ui-helpers.ts b/reference-implementation/server/routes/as-consent-ui-helpers.ts index 2ea5b94eb..66537bbb7 100644 --- a/reference-implementation/server/routes/as-consent-ui-helpers.ts +++ b/reference-implementation/server/routes/as-consent-ui-helpers.ts @@ -59,8 +59,13 @@ export interface ConsentPickerCapabilities { } export interface ConsentPickerManifest { + readonly connector_id?: string | null; readonly display_name?: string | null; + readonly manifest_uri?: string | null; readonly name?: string | null; + readonly source_declaration?: { + readonly source?: { readonly id?: string | null; readonly kind?: string | null } | null; + } | null; readonly streams?: Array<{ name: string; description?: string | null }> | null; } @@ -256,13 +261,41 @@ export function validateAuthorizePkce({ responseType, codeChallenge, codeChallen * Builds a single-entry `authorization_details` array for a connector-backed * hosted MCP authorize shortcut (wildcard streams, continuous access). */ -export function buildHostedMcpAuthorizationDetailsForConnector(connectorId: string): unknown[] { +interface HostedMcpSourceDescriptor { + id: string; + kind: "connector" | "provider_native"; +} + +/** Resolve public source identity without leaking the local storage key. */ +export function resolveHostedMcpSourceDescriptor( + manifest: ConsentPickerManifest | null | undefined +): HostedMcpSourceDescriptor | null { + const declared = manifest?.source_declaration?.source; + if ( + declared && + (declared.kind === "connector" || declared.kind === "provider_native") && + typeof declared.id === "string" && + URL.canParse(declared.id) + ) { + return { id: declared.id, kind: declared.kind }; + } + const legacyId = + typeof manifest?.manifest_uri === "string" && manifest.manifest_uri + ? manifest.manifest_uri + : manifest?.connector_id; + return typeof legacyId === "string" && URL.canParse(legacyId) ? { id: legacyId, kind: "connector" } : null; +} + +export function buildHostedMcpAuthorizationDetailsForConnector( + connectorId: string, + source: HostedMcpSourceDescriptor = { id: connectorId, kind: "connector" } +): unknown[] { return [ { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personal_ai_assistant", purpose_description: "Allow this MCP client to read selected personal data through PDPP.", - source: { id: connectorId, kind: "connector" }, + source, streams: [{ name: "*" }], type: "https://pdpp.dev/data-access", }, @@ -277,12 +310,9 @@ export function buildHostedMcpAuthorizationDetailsForConnector(connectorId: stri * to `HOSTED_MCP_PICKER_DEFAULT_ACCESS_MODE` (continuous). * * `connectionId`, when a non-empty string, pins every stream entry to that - * connection by stamping `connection_id` onto it. This is the enforcement - * lever: `resolveGrantSelection` copies `streams[].connection_id` onto the - * issued child grant, and the read-path binding resolver narrows fan-in to the - * named connection. Wildcard stream selections are pinned identically — the - * runtime narrows the binding to the connection, then expands streams under - * it. Callers MUST only pass a `connectionId` the picker presented and + * connector instance by stamping its opaque handle into `instance_ids`. + * Wildcard stream selections are pinned identically. Callers MUST only pass a + * `connectionId` the picker presented and * validated as active, and MUST omit it when the surface did not present a * specific-connection choice (single-connection or unconfigured connector), so * fan-in semantics and existing grants are preserved. @@ -291,19 +321,20 @@ export function buildHostedMcpAuthorizationDetailForConnector( connectorId: string, streamNames: string[] | null = null, accessMode: string | null = null, - connectionId: string | null = null + connectionId: string | null = null, + source: HostedMcpSourceDescriptor = { id: connectorId, kind: "connector" } ): { type: string; source: { kind: string; id: string }; purpose_code: string; purpose_description: string; access_mode: string; - streams: Array<{ name: string; connection_id?: string }>; + streams: Array<{ name: string; instance_ids?: string[] }>; } { const pinnedConnectionId = typeof connectionId === "string" && connectionId.trim() ? connectionId.trim() : null; - const withPin = (name: string): { name: string; connection_id?: string } => - pinnedConnectionId ? { connection_id: pinnedConnectionId, name } : { name }; - let streams: Array<{ name: string; connection_id?: string }>; + const withPin = (name: string): { name: string; instance_ids?: string[] } => + pinnedConnectionId ? { instance_ids: [pinnedConnectionId], name } : { name }; + let streams: Array<{ name: string; instance_ids?: string[] }>; if (Array.isArray(streamNames) && streamNames.length > 0) { streams = streamNames.map((name) => withPin(name)); } else { @@ -316,7 +347,7 @@ export function buildHostedMcpAuthorizationDetailForConnector( access_mode: resolvedAccessMode, purpose_code: HOSTED_MCP_PICKER_PURPOSE_CODE, purpose_description: HOSTED_MCP_PICKER_PURPOSE_DESCRIPTION, - source: { id: connectorId, kind: "connector" }, + source, streams, type: "https://pdpp.dev/data-access", }; @@ -553,6 +584,11 @@ export function renderPendingConsentNotFoundHtml(providerName: string, ui: Conse }); } +interface PendingClientClaims { + commitments?: string[] | null; + [key: string]: unknown; +} + export interface PendingGrantRequest { client?: { client_display?: { name?: string | null } | null; @@ -560,20 +596,16 @@ export interface PendingGrantRequest { registration_mode?: string | null; } | null; selection?: { + client_claims?: PendingClientClaims | null; streams?: Array<{ name: string; + time_constraint?: { field?: string | null; since?: string | null; until?: string | null } | null; time_range?: { since?: string | null } | null; fields?: string[] | null; + instance_ids?: string[] | null; + resources?: string[] | null; view?: string | null; necessity?: string | null; - // REQUEST-scoped, CLIENT-authored per-stream claims. Rendered as claims, - // never as protocol facts (see `buildClientClaimsBlock`). Carried through - // `normalizeStreamSelection` in server/auth.js; the renderer surfaces it. - client_claims?: { - purpose?: string | null; - commitments?: string[] | null; - [key: string]: unknown; - } | null; }> | null; access_mode?: string | null; purpose_description?: string | null; @@ -589,6 +621,86 @@ export interface PendingGrantRequest { } | null; } +interface ApprovalReviewStream { + fields: string[]; + instance_ids: string[]; + name: string; + resources?: string[]; + time_constraint?: { field: string; since?: string; until?: string }; +} + +interface ApprovalReviewClient { + client_display?: { + logo_uri?: string | null; + name?: string | null; + policy_uri?: string | null; + tos_uri?: string | null; + uri?: string | null; + } | null; + client_id: string; + registration_mode: string; +} + +interface ApprovalReviewSource { + id: string; + kind: string; +} + +interface ApprovalReviewSourceDeclaration { + accepted_revision_reference?: string; + digest: string; + publisher_attribution?: { id: string; status: "unverified" }; + resource_authority?: { authority_binding: string; status: "verified" } | { status: "local_operator_provisioned" }; + version: string; +} + +interface ApprovalReviewSourceEntry { + access_mode: string; + client_claims: PendingClientClaims | null; + index: number; + purpose_code: string; + purpose_description: string | null; + resolved_streams: ApprovalReviewStream[]; + retention: { max_duration?: string; on_expiry?: string } | null; + selection_preset: string | null; + source: ApprovalReviewSource; + source_declaration: ApprovalReviewSourceDeclaration; +} + +interface SingleApprovalReviewArtifact { + access_mode: string; + ai_training_consented: boolean | null; + client: ApprovalReviewClient; + client_claims: PendingClientClaims | null; + expires_at: string | null; + purpose_code: string; + purpose_description: string | null; + resolved_streams: ApprovalReviewStream[]; + retention: { max_duration?: string; on_expiry?: string } | null; + selection_preset: string | null; + source: ApprovalReviewSource; + source_declaration: ApprovalReviewSourceDeclaration; + subject: { id: string }; + version: "reference.approval-review.v1"; +} + +interface BatchApprovalReviewArtifact { + access_mode: string | null; + approved_source_indexes: number[]; + client: ApprovalReviewClient; + expires_at: string | null; + parent_package_id: string | null; + source_narrowing: Record< + string, + { fields?: Record<string, string[]>; since?: Record<string, string>; streams?: string[] } + >; + sources: ApprovalReviewSourceEntry[]; + subject: { id: string }; + version: "reference.batch-approval-review.v1"; +} + +type ApprovalReviewArtifact = SingleApprovalReviewArtifact | BatchApprovalReviewArtifact; + export interface PendingGrant { approveAllGate?: { approve_all_suppressed: boolean; suppression_reasons: string[] } | null; batch?: boolean; @@ -598,6 +710,10 @@ export interface PendingGrant { overCapSources?: Array<{ id?: string | null; kind?: string | null } | null> | null; overSoftCap?: boolean; request: PendingGrantRequest; + review?: ApprovalReviewArtifact | null; + reviewArtifact?: string | null; + reviewDigest?: string | null; + reviewRevision?: string | null; softCap?: number; softCapWarning?: boolean; userCode?: string | null; @@ -607,6 +723,7 @@ type StreamItem = NonNullable<NonNullable<PendingGrantRequest["selection"]>["str interface PendingConsentCard { access_mode?: string | null; + client_claims?: PendingClientClaims | null; index: number; manifestStreamNames?: string[] | null; purpose_code?: string | null; @@ -638,7 +755,7 @@ interface PendingConsentCumulativeRisk { // • MANIFEST — the owner-trusted human descriptions for the requested streams // (stream labels/details from the resolved manifest). // • CLIENT — claims the client itself authored (its self-described app name, -// the purpose_description, and per-stream client_claims). Rendered, never +// the purpose_description, and top-level client_claims). Rendered, never // trusted: each carries a "they say / not enforced" affordance. // // `data-authorship` is the machine-readable provenance hook (one per block), @@ -725,55 +842,148 @@ function buildConsentClientDisplay( } /** - * Render the per-stream `client_claims` (REQUEST-scoped, CLIENT-authored) as a - * distinct, disclaimed block. Each claim is the client's own word about why it - * wants a stream and what it commits to — the server does not vouch for any of - * it. Returns "" when no stream carries claims (so the block never appears with - * an empty body). + * Render top-level `client_claims.commitments` as a distinct, disclaimed + * client-authored block. These are the client's own commitments; the server + * renders but does not enforce them. */ -function buildClientClaimsBlock(streams: StreamItem[], ui: ConsentUiRenderer): string { - const rows: string[] = []; - for (const stream of streams) { - const claims = stream?.client_claims; - if (!claims || typeof claims !== "object") { - continue; - } - const parts: string[] = []; - const purpose = typeof claims.purpose === "string" ? claims.purpose.trim() : ""; - if (purpose) { - parts.push(`<p class="hosted-ui-client-claim-purpose">${ui.escapeHtml(purpose)}</p>`); - } - const commitments = Array.isArray(claims.commitments) - ? claims.commitments.filter((c): c is string => typeof c === "string" && c.trim() !== "") - : []; - if (commitments.length > 0) { - parts.push( - `<ul class="hosted-ui-client-claim-commitments">${commitments - .map((c) => `<li>${ui.escapeHtml(c)}</li>`) - .join("")}</ul>` - ); - } - if (parts.length === 0) { - continue; - } - rows.push( - `<div class="hosted-ui-client-claim"><span class="hosted-ui-stream-name">${ui.escapeHtml( - stream.name - )}</span>${parts.join("")}</div>` - ); +function buildClientClaimsBlock(clientClaims: PendingClientClaims | null | undefined, ui: ConsentUiRenderer): string { + if (!clientClaims || typeof clientClaims !== "object") { + return ""; } - if (rows.length === 0) { + const commitments = Array.isArray(clientClaims.commitments) + ? clientClaims.commitments.filter((c: unknown): c is string => typeof c === "string" && c.trim() !== "") + : []; + if (commitments.length === 0) { return ""; } - const body = `<span class="pdpp-title">What this app says it will do</span>${rows.join( - "" - )}<p class="hosted-ui-client-claim-disclaimer">These are the app's own claims, not enforced by your server.</p>`; + const items = commitments.map((c: string) => `<li>${ui.escapeHtml(c)}</li>`).join(""); + const body = `<span class="pdpp-title">What this app says it will do</span><ul class="hosted-ui-client-claim-commitments">${items}</ul><p class="hosted-ui-client-claim-disclaimer">These are the app's own claims, not enforced by your server.</p>`; return renderAuthorshipBlock("client", "Client-authored claims", body, ui); } +function displayOptional(value: string | null | undefined): string { + return value ?? "None"; +} + +function displayList(values: string[] | null | undefined): string { + return values && values.length > 0 ? values.join(", ") : "None"; +} + +function buildReviewedClientFacts(client: ApprovalReviewClient): Array<{ label: string; value: string }> { + return [ + { label: "Client ID", value: client.client_id }, + { label: "Registration mode", value: client.registration_mode }, + { label: "Display name", value: displayOptional(client.client_display?.name) }, + { label: "Client URI", value: displayOptional(client.client_display?.uri) }, + { label: "Logo URI", value: displayOptional(client.client_display?.logo_uri) }, + { label: "Policy URI", value: displayOptional(client.client_display?.policy_uri) }, + { label: "Terms URI", value: displayOptional(client.client_display?.tos_uri) }, + ]; +} + +function renderReviewedStreams(streams: ApprovalReviewStream[], ui: ConsentUiRenderer): string { + return streams + .map((stream) => { + const timeFacts = stream.time_constraint + ? [ + { label: "Time field", value: stream.time_constraint.field }, + { label: "Since", value: displayOptional(stream.time_constraint.since) }, + { label: "Until", value: displayOptional(stream.time_constraint.until) }, + ] + : [{ label: "Time constraint", value: "None" }]; + return ui.renderSurface({ + ariaLabel: `Reviewed stream ${stream.name}`, + children: `<h4 class="pdpp-title">${ui.escapeHtml(stream.name)}</h4>${ui.renderKeyValueList([ + { label: "Instance IDs", value: displayList(stream.instance_ids) }, + { label: "Fields", value: displayList(stream.fields) }, + { label: "Resources", value: displayList(stream.resources) }, + ...timeFacts, + ])}`, + surface: "protocol", + }); + }) + .join("\n"); +} + +function buildReviewedSelectionFacts(review: SingleApprovalReviewArtifact | ApprovalReviewSourceEntry) { + return [ + { label: "Purpose code", value: review.purpose_code }, + { label: "Purpose description", value: displayOptional(review.purpose_description) }, + { label: "Access mode", value: review.access_mode }, + { label: "Selection preset", value: displayOptional(review.selection_preset) }, + { label: "Retention duration", value: displayOptional(review.retention?.max_duration) }, + { label: "Retention on expiry", value: displayOptional(review.retention?.on_expiry) }, + ]; +} + +function buildReviewedSourceFacts( + source: ApprovalReviewSource, + declaration: ApprovalReviewSourceDeclaration +): Array<{ label: string; value: string }> { + const resourceAuthority = declaration.resource_authority; + const authorityFacts: Array<{ label: string; value: string }> = []; + if (resourceAuthority?.status === "verified") { + authorityFacts.push({ + label: "Resource authority", + value: `Verified (${resourceAuthority.authority_binding})`, + }); + } else if (resourceAuthority?.status === "local_operator_provisioned") { + authorityFacts.push({ + label: "Resource authority", + value: "Local operator provisioning (not verified discovery)", + }); + } + return [ + { label: "Source ID", value: source.id }, + { label: "Source kind", value: source.kind }, + { label: "Declaration version", value: declaration.version }, + { label: "Declaration digest", value: declaration.digest }, + ...(declaration.accepted_revision_reference + ? [{ label: "Accepted revision", value: declaration.accepted_revision_reference }] + : []), + ...authorityFacts, + ...(declaration.publisher_attribution + ? [ + { + label: "Publisher attribution", + value: `${declaration.publisher_attribution.id} (unverified)`, + }, + ] + : []), + ]; +} + +function displayAiTrainingDecision(value: boolean | null): string { + if (value === null) { + return "Not applicable"; + } + return value ? "Agreed" : "Not agreed"; +} + +function renderReviewedNarrowing( + narrowing: BatchApprovalReviewArtifact["source_narrowing"][string] | undefined, + ui: ConsentUiRenderer +): string { + if (!narrowing) { + return ui.renderKeyValueList([{ label: "Owner narrowing", value: "None" }]); + } + const fieldEntries = narrowing.fields + ? Object.entries(narrowing.fields).map(([stream, fields]) => `${stream}: ${displayList(fields)}`) + : []; + const sinceEntries = narrowing.since + ? Object.entries(narrowing.since).map(([stream, since]) => `${stream}: ${since}`) + : []; + return ui.renderKeyValueList([ + { label: "Streams kept", value: displayList(narrowing.streams) }, + { label: "Field narrowing", value: displayList(fieldEntries) }, + { label: "Time narrowing", value: displayList(sinceEntries) }, + ]); +} + function renderRequestedStreamItem(stream: StreamItem, ui: ConsentUiRenderer): string { + const since = stream.time_constraint?.since ?? stream.time_range?.since; const fragments = [ - stream.time_range ? `since ${stream.time_range.since || "any"}` : null, + since ? `since ${since}` : null, stream.fields ? `fields: ${stream.fields.join(", ")}` : null, stream.view ? `view: ${stream.view}` : null, stream.necessity === "optional" ? "optional" : null, @@ -860,8 +1070,8 @@ function buildBatchSourceCards(cards: PendingConsentCard[], ui: ConsentUiRendere ]), ui ); - // CLIENT: the client-authored purpose for this source plus any per-stream - // client_claims. Rendered as claims, never as facts. + // CLIENT: the client-authored purpose for this source. Rendered as a + // claim, never as a fact. const clientPurpose = card.purpose_code || "unspecified"; const clientPurposeBlock = renderAuthorshipBlock( "client", @@ -869,7 +1079,7 @@ function buildBatchSourceCards(cards: PendingConsentCard[], ui: ConsentUiRendere ui.renderKeyValueList([{ label: "Stated purpose", value: clientPurpose }]), ui ); - const clientClaimsBlock = buildClientClaimsBlock(streams, ui); + const clientClaimsBlock = buildClientClaimsBlock(card.client_claims, ui); return ui.renderSurface({ ariaLabel: `Source ${card.index + 1}`, children: `<h3 class="pdpp-title">${ui.escapeHtml( @@ -922,7 +1132,7 @@ function buildSourceNarrowingControls(card: PendingConsentCard, ui: ConsentUiRen .join("")}</div>` : ""; - const since = stream.time_range?.since; + const since = stream.time_constraint?.since; const sinceControl = since ? `<label class="hosted-ui-narrow-since">Start no earlier than <input type="text" name="narrow_since_${index}__${encoded}" value="${ui.escapeHtml( since @@ -947,6 +1157,7 @@ const APPROVE_ALL_SUPPRESSION_LABELS: Record<string, string> = { function buildPerSourceConfirmForm( cards: PendingConsentCard[], requestUri: string, + reviewRevision: string | null | undefined, csrfToken: string | null, csrfFieldName: string, ui: ConsentUiRenderer @@ -963,8 +1174,11 @@ function buildPerSourceConfirmForm( )}" checked /> ${ui.escapeHtml(sourceLabel)}</label>${narrowControls}</div>`; }) .join("\n"); - return `<form class="hosted-ui-form" method="POST" action="/consent/approve" aria-label="Confirm each source"> -${csrfInput}<input type="hidden" name="request_uri" value="${ui.escapeHtml(requestUri)}" /> + const reviewInput = reviewRevision + ? `<input type="hidden" name="approval_review_revision" value="${ui.escapeHtml(reviewRevision)}" />` + : ""; + return `<form class="hosted-ui-form" method="POST" action="/consent/review" aria-label="Confirm each source"> + ${csrfInput}${reviewInput}<input type="hidden" name="request_uri" value="${ui.escapeHtml(requestUri)}" /> <div class="hosted-ui-source-toggles"><span class="pdpp-title">Confirm each source</span>${checkboxes}</div> <button type="submit" class="hosted-ui-button" data-variant="primary">Confirm selected sources</button> </form>`; @@ -981,13 +1195,119 @@ function buildApproveAllForm( ? `<input type="hidden" name="${ui.escapeHtml(csrfFieldName)}" value="${ui.escapeHtml(csrfToken)}" />` : ""; const sourceList = cards.map((card) => ui.escapeHtml(card.source?.id || `source ${card.index + 1}`)).join(", "); - return `<form class="hosted-ui-form" method="POST" action="/consent/approve" aria-label="Allow all sources"> -${csrfInput}<input type="hidden" name="request_uri" value="${ui.escapeHtml(requestUri)}" /> + return `<form class="hosted-ui-form" method="POST" action="/consent/review" aria-label="Allow all sources"> + ${csrfInput}<input type="hidden" name="request_uri" value="${ui.escapeHtml(requestUri)}" /> <label class="hosted-ui-source-toggle"><input type="checkbox" name="confirm_approve_all" value="1" required /> I confirm allowing all ${cards.length} sources: ${sourceList}</label> <button type="submit" class="hosted-ui-button" data-variant="default">Allow all sources</button> </form>`; } +function buildFinalBatchReviewForm( + cards: PendingConsentCard[], + requestUri: string, + reviewRevision: string, + csrfToken: string | null, + csrfFieldName: string, + ui: ConsentUiRenderer +): string { + const csrfInput = csrfToken + ? `<input type="hidden" name="${ui.escapeHtml(csrfFieldName)}" value="${ui.escapeHtml(csrfToken)}" />` + : ""; + const sourceList = cards + .map((card) => `<li>${ui.escapeHtml(card.source?.id || `source ${card.index + 1}`)}</li>`) + .join(""); + return `<form class="hosted-ui-form" method="POST" action="/consent/approve" aria-label="Confirm reviewed batch decision"> + ${csrfInput}<input type="hidden" name="request_uri" value="${ui.escapeHtml(requestUri)}" /> + <input type="hidden" name="approval_review_revision" value="${ui.escapeHtml(reviewRevision)}" /> +<div class="hosted-ui-source-toggles"><span class="pdpp-title">Reviewed sources</span><ul>${sourceList}</ul></div> +<label class="hosted-ui-source-toggle"><input type="checkbox" name="confirm_reviewed_decision" value="1" required /> I confirm this reviewed decision</label> +<button type="submit" class="hosted-ui-button" data-variant="primary">Approve reviewed decision</button> +</form>`; +} + +function renderReviewedBatchConsentHtml( + review: BatchApprovalReviewArtifact, + pending: PendingGrant, + requestUri: string, + csrfToken: string | null, + csrfFieldName: string, + providerName: string, + ui: ConsentUiRenderer +): string { + if (!pending.reviewRevision) { + throw new Error("Reviewed batch consent is missing its approval revision"); + } + const cards: PendingConsentCard[] = review.sources.map((source) => ({ + access_mode: source.access_mode, + client_claims: source.client_claims, + index: source.index, + purpose_code: source.purpose_code, + resolvedStreams: source.resolved_streams, + retention: source.retention, + source: source.source, + })); + const csrfHidden = csrfToken ? [{ name: csrfFieldName, value: csrfToken }] : []; + const denyForm = ui.renderActionRow([ + { + action: "/consent/deny", + hidden: [...csrfHidden, { name: "request_uri", value: requestUri }], + label: "Deny", + method: "POST", + variant: "danger", + }, + ]); + const sourceSections = review.sources + .map((source, order) => + ui.renderSurface({ + ariaLabel: `Reviewed source ${source.index + 1}`, + children: [ + `<h3 class="pdpp-heading">${ui.escapeHtml(source.source.id)}</h3>`, + ui.renderKeyValueList([ + { label: "Approval order", value: order + 1 }, + { label: "Staged source index", value: source.index }, + ...buildReviewedSourceFacts(source.source, source.source_declaration), + ...buildReviewedSelectionFacts(source), + ]), + buildClientClaimsBlock(source.client_claims, ui), + renderReviewedNarrowing(review.source_narrowing[String(source.index)], ui), + `<span class="pdpp-title">Exact reviewed streams</span>${renderReviewedStreams(source.resolved_streams, ui)}`, + ].join("\n"), + surface: "human", + }) + ) + .join("\n"); + const actions = [ + buildFinalBatchReviewForm(cards, requestUri, pending.reviewRevision, csrfToken, csrfFieldName, ui), + denyForm, + ].join("\n"); + const body = [ + ui.renderPageIntro({ + eyebrow: "Final approval", + lede: "These are the exact facts your server saved when you completed review.", + title: "Approve the reviewed sources", + }), + ui.renderSurface({ + ariaLabel: "Reviewed batch decision", + children: ui.renderKeyValueList([ + ...buildReviewedClientFacts(review.client), + { label: "Subject ID", value: review.subject.id }, + { label: "Access mode", value: displayOptional(review.access_mode) }, + { label: "Grant expiry", value: displayOptional(review.expires_at) }, + { label: "Parent package ID", value: displayOptional(review.parent_package_id) }, + { label: "Approved source order", value: review.approved_source_indexes.join(", ") }, + ]), + surface: "human", + }), + sourceSections, + ui.renderSurface({ ariaLabel: "Consent actions", children: actions, surface: "human" }), + ].join("\n"); + return ui.renderHostedDocument({ + body, + providerName, + title: `${providerName} — Reviewed batch consent`, + }); +} + function renderBatchConsentHtml( pending: PendingGrant, requestUri: string, @@ -996,6 +1316,17 @@ function renderBatchConsentHtml( providerName: string, ui: ConsentUiRenderer ): string { + if (pending.review?.version === "reference.batch-approval-review.v1") { + return renderReviewedBatchConsentHtml( + pending.review, + pending, + requestUri, + csrfToken, + csrfFieldName, + providerName, + ui + ); + } // biome-ignore lint/style/useDestructuring: Explicit property or positional access documents this compatibility boundary. const request = pending.request; const client = request.client || {}; @@ -1037,14 +1368,18 @@ function renderBatchConsentHtml( variant: "danger", }, ]); - const actions = [ - suppressionNote, - buildPerSourceConfirmForm(cards, requestUri, csrfToken, csrfFieldName, ui), - approveAllSuppressed ? "" : buildApproveAllForm(cards, requestUri, csrfToken, csrfFieldName, ui), - denyForm, - ] - .filter(Boolean) - .join("\n"); + const actions = pending.reviewRevision + ? [buildFinalBatchReviewForm(cards, requestUri, pending.reviewRevision, csrfToken, csrfFieldName, ui), denyForm] + .filter(Boolean) + .join("\n") + : [ + suppressionNote, + buildPerSourceConfirmForm(cards, requestUri, null, csrfToken, csrfFieldName, ui), + approveAllSuppressed ? "" : buildApproveAllForm(cards, requestUri, csrfToken, csrfFieldName, ui), + denyForm, + ] + .filter(Boolean) + .join("\n"); const body = [ ui.renderPageIntro({ @@ -1087,6 +1422,133 @@ function renderBatchConsentHtml( }); } +function hiddenInputs(fields: Array<{ name: string; value: string }>, ui: ConsentUiRenderer): string { + return fields + .map((field) => `<input type="hidden" name="${ui.escapeHtml(field.name)}" value="${ui.escapeHtml(field.value)}" />`) + .join(""); +} + +function buildSingleConsentActions({ + csrfFieldName, + csrfToken, + isAiTraining, + pending, + requestUri, + ui, +}: { + csrfFieldName: string; + csrfToken: string | null; + isAiTraining: boolean; + pending: PendingGrant; + requestUri: string; + ui: ConsentUiRenderer; +}): string { + const csrfHidden = csrfToken ? [{ name: csrfFieldName, value: csrfToken }] : []; + const reviewHidden = pending.reviewRevision + ? [{ name: "approval_review_revision", value: pending.reviewRevision }] + : []; + const allowAction = pending.reviewRevision + ? ui.renderActionRow([ + { + action: "/consent/approve", + hidden: [...csrfHidden, ...reviewHidden, { name: "request_uri", value: requestUri }], + label: "Allow access", + method: "POST", + variant: "primary", + }, + ]) + : `<form class="hosted-ui-form" method="POST" action="/consent/review" aria-label="Finalize consent review"> +${hiddenInputs(csrfHidden, ui)}<input type="hidden" name="request_uri" value="${ui.escapeHtml(requestUri)}" /> +${ + isAiTraining + ? '<label class="hosted-ui-source-toggle"><input type="checkbox" name="ai_training_consented" value="1" required /> I explicitly agree to AI training use</label>' + : "" +} +<button type="submit" class="hosted-ui-button" data-variant="primary">Allow access</button> +</form>`; + const denyAction = ui.renderActionRow([ + { + action: "/consent/deny", + hidden: [...csrfHidden, { name: "request_uri", value: requestUri }], + label: "Deny", + method: "POST", + variant: "danger", + }, + ]); + return [allowAction, denyAction].join("\n"); +} + +function renderReviewedSingleConsentHtml( + review: SingleApprovalReviewArtifact, + pending: PendingGrant, + requestUri: string, + csrfToken: string | null, + csrfFieldName: string, + providerName: string, + ui: ConsentUiRenderer +): string { + const actions = buildSingleConsentActions({ + csrfFieldName, + csrfToken, + isAiTraining: false, + pending, + requestUri, + ui, + }); + const body = [ + ui.renderPageIntro({ + eyebrow: "Final approval", + lede: "These are the exact facts your server saved when you completed review.", + title: "Approve the reviewed data access", + }), + ui.renderSurface({ + ariaLabel: "Reviewed consent decision", + children: [ + renderAuthorshipBlock( + "protocol", + "Reviewed client and subject", + ui.renderKeyValueList([ + ...buildReviewedClientFacts(review.client), + { label: "Subject ID", value: review.subject.id }, + ]), + ui + ), + renderAuthorshipBlock( + "protocol", + "Reviewed source declaration", + ui.renderKeyValueList(buildReviewedSourceFacts(review.source, review.source_declaration)), + ui + ), + renderAuthorshipBlock( + "client", + "Reviewed purpose", + [ui.renderKeyValueList(buildReviewedSelectionFacts(review)), buildClientClaimsBlock(review.client_claims, ui)] + .filter(Boolean) + .join("\n"), + ui + ), + renderAuthorshipBlock( + "protocol", + "Reviewed approval conditions", + ui.renderKeyValueList([ + { label: "AI training consent", value: displayAiTrainingDecision(review.ai_training_consented) }, + { label: "Grant expiry", value: displayOptional(review.expires_at) }, + ]), + ui + ), + `<span class="pdpp-title">Exact reviewed streams</span>${renderReviewedStreams(review.resolved_streams, ui)}`, + actions, + ].join("\n"), + surface: "human", + }), + ].join("\n"); + return ui.renderHostedDocument({ + body, + providerName, + title: `${providerName} — Reviewed consent`, + }); +} + /** * Renders the active consent review page for GET /consent when a live * pending-consent row exists. The owner reviews streams, facts, and submits @@ -1103,6 +1565,17 @@ export function renderPendingGrantConsentHtml( if (pending.batch) { return renderBatchConsentHtml(pending, requestUri, csrfToken, csrfFieldName, providerName, ui); } + if (pending.review?.version === "reference.approval-review.v1") { + return renderReviewedSingleConsentHtml( + pending.review, + pending, + requestUri, + csrfToken, + csrfFieldName, + providerName, + ui + ); + } // biome-ignore lint/style/useDestructuring: Explicit property or positional access documents this compatibility boundary. const request = pending.request; @@ -1167,9 +1640,8 @@ export function renderPendingGrantConsentHtml( ? renderAuthorshipBlock("client", "Client-authored display", ui.renderKeyValueList(clientFactsRaw), ui) : ""; - // CLIENT: per-stream client_claims (purpose/commitments), if any. Previously - // dropped entirely — now surfaced as a distinct, disclaimed claims block. - const clientClaimsBlock = buildClientClaimsBlock(requestedStreams, ui); + // CLIENT: top-level client_claims.commitments, if any. + const clientClaimsBlock = buildClientClaimsBlock(selection.client_claims, ui); // MANIFEST: the streams the owner's server is being asked to project, named // and described by the resolved manifest (owner-trusted human descriptions). @@ -1178,24 +1650,14 @@ export function renderPendingGrantConsentHtml( const codeBlock = pending.userCode ? `<div><span class="pdpp-eyebrow">Verification code</span><div class="hosted-ui-code">${ui.escapeHtml(pending.userCode)}</div></div>` : ""; - - const csrfHidden = csrfToken ? [{ name: csrfFieldName, value: csrfToken }] : []; - const actions = ui.renderActionRow([ - { - action: "/consent/approve", - hidden: [...csrfHidden, { name: "request_uri", value: requestUri }], - label: "Allow access", - method: "POST", - variant: "primary", - }, - { - action: "/consent/deny", - hidden: [...csrfHidden, { name: "request_uri", value: requestUri }], - label: "Deny", - method: "POST", - variant: "danger", - }, - ]); + const actions = buildSingleConsentActions({ + csrfFieldName, + csrfToken, + isAiTraining: selection.purpose_code === "https://pdpp.dev/purpose/ai_training", + pending, + requestUri, + ui, + }); const body = [ ui.renderPageIntro({ diff --git a/reference-implementation/server/routes/as-consent.ts b/reference-implementation/server/routes/as-consent.ts index ebb803ac7..d97aa62dd 100644 --- a/reference-implementation/server/routes/as-consent.ts +++ b/reference-implementation/server/routes/as-consent.ts @@ -32,6 +32,7 @@ import type { import { executeAsConsentDecision } from "../../operations/as-consent-decision/index.ts"; import type { AsConsentExchangeConsumeResult } from "../../operations/as-consent-exchange/index.ts"; import { executeAsConsentExchange } from "../../operations/as-consent-exchange/index.ts"; +import { applyCredentialResponseNoStoreHeaders } from "../credential-response-cache.ts"; import { OWNER_AUTH_DEFAULT_SUBJECT_ID } from "../owner-auth.ts"; import type { PdppErrorFn, RouteArg } from "./_route-contract.ts"; import type { ConsentUiRenderer, PendingGrant } from "./as-consent-ui-helpers.ts"; @@ -80,7 +81,7 @@ interface OwnerAuth { interface ConsentStore { approveGrant: ( deviceCode: string, - subjectId: string, + subjectId: string | undefined, opts: unknown ) => Promise<{ grant: { grant_id: string; [k: string]: unknown }; @@ -92,7 +93,15 @@ interface ConsentStore { getPendingConsentByApprovalId: (id: string) => Promise<AsConsentDecisionPendingRow | null>; getPendingConsentByDeviceCode: ( deviceCode: string, - opts?: { baseUrl?: string | null } + opts?: { + ai_training_consented?: unknown; + approvedSourceIndexes?: number[]; + baseUrl?: string | null; + confirmedApproveAll?: boolean; + finalizeReview?: boolean; + sourceNarrowing?: Record<number, SourceNarrowing>; + subjectId?: string | null; + } ) => Promise<PendingGrant | null>; parseRequestUri: (requestUri: string) => string | null; } @@ -100,8 +109,8 @@ interface ConsentStore { // ─── agentConnectAttemptStore surface used by this adapter ──────────────────── interface AgentConnectAttemptStore { - complete: (requestUri: string | null | undefined, result: unknown) => void; - fail: (requestUri: string | null | undefined, reason: string) => void; + complete: (requestUri: string | null | undefined, result: unknown) => Promise<void>; + fail: (requestUri: string | null | undefined, reason: string) => Promise<void>; } // ─── Context injected by the composition root ───────────────────────────────── @@ -112,9 +121,15 @@ export interface MountAsConsentContext { consentStore: ConsentStore; consentUi: ConsentUiRenderer; consumeConsentExchangeCode: ( - code: string + code: string, + proof?: string | null | undefined ) => Promise<AsConsentExchangeConsumeResult> | AsConsentExchangeConsumeResult; - createConsentExchangeCode: (opts: { grantId: string; token: string; grant: unknown }) => string; + createConsentExchangeCode: (opts: { + grantId: string; + token: string; + grant: Record<string, unknown>; + recoveryProof?: string; + }) => Promise<string> | string; handleError: (res: unknown, err: unknown) => void; issueOAuthAuthorizationCodeForDeviceCode: ( deviceCode: string | null, @@ -133,12 +148,12 @@ export interface MountAsConsentContext { // ─── Internal helpers ───────────────────────────────────────────────────────── -function renderApproveHtml( +async function renderApproveHtml( ctx: MountAsConsentContext, grant: { grant_id: string; [k: string]: unknown }, token: string -): string { - const exchangeCode = ctx.createConsentExchangeCode({ grant, grantId: grant.grant_id, token }); +): Promise<string> { + const exchangeCode = await ctx.createConsentExchangeCode({ grant, grantId: grant.grant_id, token }); return ctx.consentUi.renderHostedDocument({ body: [ ctx.consentUi.renderPageIntro({ @@ -172,12 +187,14 @@ function renderApproveHtml( }); } -function renderPackageApproveHtml( +async function renderPackageApproveHtml( ctx: MountAsConsentContext, grant: { grant_id: string; child_grants?: Array<{ grant_id?: string }>; [k: string]: unknown }, - packageId: string -): string { + packageId: string, + token: string +): Promise<string> { const childGrants = Array.isArray(grant.child_grants) ? grant.child_grants : []; + const exchangeCode = await ctx.createConsentExchangeCode({ grant, grantId: packageId, token }); return ctx.consentUi.renderHostedDocument({ body: [ ctx.consentUi.renderPageIntro({ @@ -203,6 +220,11 @@ function renderPackageApproveHtml( .join("<br>"), label: "Child grant IDs", }, + { + html: `<code>${ctx.consentUi.escapeHtml(exchangeCode)}</code>`, + label: "Consent exchange code", + }, + { html: "<code>POST /consent/exchange</code>", label: "Redeem at" }, ]), surface: "protocol", }), @@ -245,9 +267,10 @@ async function dispatchApproveResponse( res.redirect(302, buildOAuthRedirectUrl(oauthCode)); return; } - ctx.agentConnectAttemptStore.complete(approvedRequestUri, { grant, status: "approved", token }); + await ctx.agentConnectAttemptStore.complete(approvedRequestUri, { grant, status: "approved", token }); const wantsJson = req.is("application/json") || req.accepts(["html", "json"]) === "json"; if (wantsJson) { + applyCredentialResponseNoStoreHeaders(res); if (isPackage) { res.json({ grant, package_id: packageInfo?.package_id ?? grant.grant_id, token }); return; @@ -256,7 +279,8 @@ async function dispatchApproveResponse( return; } if (isPackage) { - res.send(renderPackageApproveHtml(ctx, grant, packageInfo?.package_id ?? grant.grant_id)); + applyCredentialResponseNoStoreHeaders(res); + res.send(await renderPackageApproveHtml(ctx, grant, packageInfo?.package_id ?? grant.grant_id, token)); return; } // The HTML approval surface is the human-hosted owner consent page. The @@ -267,7 +291,8 @@ async function dispatchApproveResponse( // at POST /consent/exchange to receive the bearer in a JSON body. // Spec: openspec/changes/harden-consent-token-handoff/specs/ // reference-implementation-architecture/spec.md - res.send(renderApproveHtml(ctx, grant, token)); + applyCredentialResponseNoStoreHeaders(res); + res.send(await renderApproveHtml(ctx, grant, token)); } // Owner per-source narrowing keyed by staged source index. The narrowing @@ -334,8 +359,23 @@ function parseStructuredSourceNarrowing(raw: unknown): Record<number, SourceNarr const out: Record<number, SourceNarrowing> = {}; for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { const index = Number(key); - if (!(Number.isInteger(index) && value) || typeof value !== "object") { - continue; + if (!CANONICAL_NON_NEGATIVE_INTEGER_KEY_RE.test(key)) { + const err = new Error(`source_narrowing key '${key}' must be a staged source index`) as Error & { + code?: string; + param?: string; + }; + err.code = "invalid_request"; + err.param = "source_narrowing"; + throw err; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + const err = new Error(`source_narrowing entry '${key}' must be an object`) as Error & { + code?: string; + param?: string; + }; + err.code = "invalid_request"; + err.param = "source_narrowing"; + throw err; } const entry = value as Record<string, unknown>; const narrowing: SourceNarrowing = {}; @@ -355,6 +395,7 @@ function parseStructuredSourceNarrowing(raw: unknown): Record<number, SourceNarr return Object.keys(out).length > 0 ? out : undefined; } +const CANONICAL_NON_NEGATIVE_INTEGER_KEY_RE = /^(0|[1-9][0-9]*)$/; const NARROW_STREAMS_KEY = /^narrow_streams_(\d+)$/; const NARROW_FIELDS_KEY = /^narrow_fields_(\d+)__(.+)$/; const NARROW_SINCE_KEY = /^narrow_since_(\d+)__(.+)$/; @@ -497,6 +538,29 @@ function parseBatchApproveSelection(body: Readonly<Record<string, unknown>> | un return out; } +function hasBatchApproveSelection(selection: { + approvedSourceIndexes?: readonly number[]; + confirmedApproveAll?: boolean; + sourceNarrowing?: Readonly<Record<number, SourceNarrowing>>; +}): boolean { + return ( + selection.approvedSourceIndexes !== undefined || + selection.confirmedApproveAll === true || + selection.sourceNarrowing !== undefined + ); +} + +function hasBatchOnlyApproveSelection(selection: { + approvedSourceIndexes?: readonly number[]; + confirmedApproveAll?: boolean; +}): boolean { + return selection.approvedSourceIndexes !== undefined || selection.confirmedApproveAll === true; +} + +function isReviewedDecisionConfirmed(value: unknown): boolean { + return value === true || value === "true" || value === "1" || value === "on"; +} + // ─── Route mount ───────────────────────────────────────────────────────────── export function mountAsConsent(app: AppLike, ctx: MountAsConsentContext): void { @@ -510,7 +574,7 @@ export function mountAsConsent(app: AppLike, ctx: MountAsConsentContext): void { async function getPendingGrantFromRequestUri( requestUri: string, - opts: { baseUrl?: string | null } = {} + opts: { baseUrl?: string | null; subjectId?: string | null } = {} ): Promise<{ deviceCode: string | null; pending: PendingGrant | null; @@ -541,19 +605,97 @@ export function mountAsConsent(app: AppLike, ctx: MountAsConsentContext): void { }; } + function resolveSubjectId(req: RouteRequest): string { + return ctx.ownerAuth.enabled + ? ctx.ownerAuth.subjectId + : (req.body?.subject_id as string | undefined) || + (req.query.subject_id as string | undefined) || + OWNER_AUTH_DEFAULT_SUBJECT_ID; + } + + function requestUriFrom(req: RouteRequest): string | null | undefined { + return (req.body?.request_uri || req.query.request_uri) as string | null | undefined; + } + + async function resolveConsentReviewRequestUri(req: RouteRequest): Promise<string | null> { + const requestUri = requestUriFrom(req); + const approvalId = (req.body?.approval_id || req.query.approval_id) as string | null | undefined; + if (requestUri && approvalId) { + const err = new Error("Specify either request_uri or approval_id, not both") as Error & { + code?: string; + }; + err.code = "invalid_request"; + throw err; + } + if (requestUri) { + return requestUri; + } + if (!approvalId) { + return null; + } + const pending = await ctx.consentStore.getPendingConsentByApprovalId(approvalId); + if (pending?.status !== "pending") { + return null; + } + return ctx.buildPendingConsentRequestUri(pending.device_code); + } + + async function rejectInvalidReviewedBatchApproval( + req: RouteRequest, + res: RouteResponse, + requestUri: string | null | undefined, + batchSelection: ReturnType<typeof parseBatchApproveSelection> + ): Promise<boolean> { + if (!(requestUri && req.body?.approval_review_revision)) { + return false; + } + const deviceCode = ctx.consentStore.parseRequestUri(requestUri); + if (!deviceCode) { + return false; + } + const pending = await ctx.consentStore.getPendingConsentByDeviceCode(deviceCode, { + baseUrl: resolveBaseUrlForRequest(req), + }); + if (!pending?.batch) { + return false; + } + if (hasBatchApproveSelection(batchSelection)) { + ctx.pdppError(res, 400, "invalid_request", "Reviewed batch approval must not submit source choices again"); + return true; + } + if (!isReviewedDecisionConfirmed(req.body.confirm_reviewed_decision)) { + ctx.pdppError(res, 400, "invalid_request", "Reviewed batch approval requires confirmation"); + return true; + } + return false; + } + + function rejectFinalApprovalFrozenFacts(req: RouteRequest, res: RouteResponse): boolean { + if (req.body?.subject_id !== undefined || req.query.subject_id !== undefined) { + ctx.pdppError(res, 400, "invalid_request", "subject_id is only accepted during consent review"); + return true; + } + if (req.body?.ai_training_consented !== undefined) { + ctx.pdppError(res, 400, "invalid_request", "ai_training_consented is only accepted during consent review"); + return true; + } + return false; + } + // Primary consent shell for the current provider-connect request/approval profile. app.get( "/consent", ctx.ownerAuth.requireOwnerSession as RouteArg<RouteHandler | MiddlewareFn>, async (req: RouteRequest, res: RouteResponse): Promise<void> => { try { - const requestUri = typeof req.query.request_uri === "string" ? req.query.request_uri : null; + const requestUri = await resolveConsentReviewRequestUri(req); if (!requestUri) { - ctx.pdppError(res, 400, "invalid_request", "request_uri is required"); + ctx.pdppError(res, 400, "invalid_request", "request_uri or approval_id is required"); return; } const { pending } = await getPendingGrantFromRequestUri(requestUri, { baseUrl: resolveBaseUrlForRequest(req), + ...(ctx.ownerAuth.enabled ? { subjectId: ctx.ownerAuth.subjectId } : {}), }); if (!pending) { res.status(404).send(renderPendingConsentNotFoundHtml(ctx.providerName, ctx.consentUi)); @@ -576,6 +718,74 @@ export function mountAsConsent(app: AppLike, ctx: MountAsConsentContext): void { } ); + app.post( + "/consent/review", + { contract: "reviewConsent" } as RouteArg<RouteHandler | MiddlewareFn>, + ctx.ownerAuth.requireOwnerSession as RouteArg<RouteHandler | MiddlewareFn>, + ctx.ownerAuth.requireCsrf as RouteArg<RouteHandler | MiddlewareFn>, + async (req: RouteRequest, res: RouteResponse): Promise<void> => { + try { + const subjectId = resolveSubjectId(req); + const batchSelection = parseBatchApproveSelection(req.body); + const requestUri = await resolveConsentReviewRequestUri(req); + if (!requestUri) { + ctx.pdppError(res, 400, "invalid_request", "request_uri or approval_id is required"); + return; + } + const deviceCode = ctx.consentStore.parseRequestUri(requestUri); + if (!deviceCode) { + ctx.pdppError(res, 400, "invalid_request", "request_uri is invalid"); + return; + } + const pending = await ctx.consentStore.getPendingConsentByDeviceCode(deviceCode, { + ai_training_consented: req.body?.ai_training_consented, + baseUrl: resolveBaseUrlForRequest(req), + finalizeReview: true, + subjectId, + ...batchSelection, + }); + if (!pending) { + res.status(404).send(renderPendingConsentNotFoundHtml(ctx.providerName, ctx.consentUi)); + return; + } + if (pending.batch !== true && hasBatchOnlyApproveSelection(batchSelection)) { + ctx.pdppError(res, 400, "invalid_request", "Single approval review does not accept batch choices"); + return; + } + if (!pending.reviewRevision) { + ctx.pdppError(res, 400, "invalid_request", "Approval review could not be finalized"); + return; + } + if (req.accepts(["html", "json"]) === "json") { + if (!pending.review) { + ctx.pdppError(res, 400, "invalid_request", "Approval review artifact is unavailable"); + return; + } + res.json({ + approval_review: pending.review, + approval_review_revision: pending.reviewRevision, + batch: pending.batch === true, + request_uri: requestUri, + }); + return; + } + const csrfToken = ctx.ownerAuth.ensureCsrfToken(req, res); + res.send( + renderPendingGrantConsentHtml( + pending, + requestUri, + csrfToken, + ctx.ownerAuth.csrfFieldName, + ctx.providerName, + ctx.consentUi + ) + ); + } catch (err) { + ctx.handleError(res, err); + } + } + ); + // Consent approve/deny decision semantics (approval_id → request_uri // resolution, deviceCode resolution, store call, error mapping) live // in the canonical `as.consent.decision` operation @@ -590,24 +800,23 @@ export function mountAsConsent(app: AppLike, ctx: MountAsConsentContext): void { ctx.ownerAuth.requireCsrf as RouteArg<RouteHandler | MiddlewareFn>, async (req: RouteRequest, res: RouteResponse): Promise<void> => { try { - const subjectId = ctx.ownerAuth.enabled - ? ctx.ownerAuth.subjectId - : (req.body?.subject_id as string | undefined) || - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - (req.query?.subject_id as string | undefined) || - OWNER_AUTH_DEFAULT_SUBJECT_ID; + const batchSelection = parseBatchApproveSelection(req.body); + const requestUri = requestUriFrom(req); + if (rejectFinalApprovalFrozenFacts(req, res)) { + return; + } + if (await rejectInvalidReviewedBatchApproval(req, res, requestUri, batchSelection)) { + return; + } const outcome = await executeAsConsentDecision( { action: "approve", - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - approvalId: (req.body?.approval_id || req.query?.approval_id) as string | null | undefined, + approvalId: (req.body?.approval_id || req.query.approval_id) as string | null | undefined, approveOptions: { - ai_training_consented: req.body?.ai_training_consented, - ...parseBatchApproveSelection(req.body), + approval_review_revision: req.body?.approval_review_revision, + ...(req.body?.approval_review_revision ? {} : batchSelection), }, - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - requestUri: (req.body?.request_uri || req.query?.request_uri) as string | null | undefined, - subjectId, + requestUri, }, buildConsentDecisionDeps(req) ); @@ -675,11 +884,11 @@ export function mountAsConsent(app: AppLike, ctx: MountAsConsentContext): void { if (outcome.traceContext?.trace_id) { ctx.setReferenceTraceId(res, outcome.traceContext.trace_id); } - ctx.agentConnectAttemptStore.fail( - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - (req.body?.request_uri || req.query?.request_uri) as string | undefined, - "denied" - ); + if (outcome.action !== "deny") { + ctx.pdppError(res, 500, "server_error", "Consent denial returned an invalid outcome"); + return; + } + await ctx.agentConnectAttemptStore.fail(outcome.requestUri, "denied"); res.send( ctx.consentUi.renderHostedDocument({ body: [ @@ -719,8 +928,12 @@ export function mountAsConsent(app: AppLike, ctx: MountAsConsentContext): void { { contract: "exchangeConsentCode" } as RouteArg<RouteHandler | MiddlewareFn>, async (req: RouteRequest, res: RouteResponse): Promise<void> => { try { + applyCredentialResponseNoStoreHeaders(res); const outcome = await executeAsConsentExchange( - { code: typeof req.body?.code === "string" ? req.body.code : null }, + { + code: typeof req.body?.code === "string" ? req.body.code : null, + proof: typeof req.body?.proof === "string" ? req.body.proof : null, + }, { consumeConsentExchangeCode: ctx.consumeConsentExchangeCode } ); if (outcome.outcome === "success") { diff --git a/reference-implementation/server/routes/as-oauth.ts b/reference-implementation/server/routes/as-oauth.ts index 83d35e4fd..d39905f25 100644 --- a/reference-implementation/server/routes/as-oauth.ts +++ b/reference-implementation/server/routes/as-oauth.ts @@ -38,6 +38,7 @@ import type { AsDeviceTokenExchangeStoreResult } from "../../operations/as-devic import { executeAsDeviceTokenExchange } from "../../operations/as-device-token-exchange/index.ts"; import type { AsIntrospectInfo } from "../../operations/as-introspect/index.ts"; import { executeAsIntrospect } from "../../operations/as-introspect/index.ts"; +import { applyCredentialResponseNoStoreHeaders } from "../credential-response-cache.ts"; import type { PdppErrorFn, RouteArg } from "./_route-contract.ts"; // Express-shaped surface, structurally typed to avoid pulling in the @@ -62,8 +63,6 @@ interface AppLike { post: (path: string, ...args: RouteArg<RouteHandler>[]) => AppLike; } -const HOSTED_MCP_OAUTH_ACCESS_TOKEN_EXPIRES_IN_SECONDS = 365 * 24 * 60 * 60; - // Narrows an unknown body field to `string | null | undefined` as required by // operation input types. Non-string values are treated as absent (undefined). function bodyString(value: unknown): string | null | undefined { @@ -249,6 +248,8 @@ export interface MountAsTokenContext { codeVerifier: unknown; }) => Promise<{ access_token: string; + access_token_expires_at?: string; + authorization_details?: unknown[]; token_type: string; refresh_token?: string | null; grant_id?: string | null; @@ -260,6 +261,7 @@ export interface MountAsTokenContext { */ exchangeOAuthRefreshToken: (args: { refreshToken: unknown; clientId: unknown }) => Promise<{ access_token: string; + access_token_expires_at?: string; token_type: string; refresh_token: string; grant_id?: string | null; @@ -278,6 +280,22 @@ function buildGrantIdPayload(token: { return token.grant_package_id ? { grant_package_id: token.grant_package_id } : { grant_id: token.grant_id }; } +function respondWithTokenJson(res: RouteResponse, body: unknown): unknown { + applyCredentialResponseNoStoreHeaders(res); + return res.json(body); +} + +function accessTokenLifetimePayload(expiresAt: string | undefined): { expires_in: number } | Record<string, never> { + if (!expiresAt) { + return {}; + } + const expiresAtMs = Date.parse(expiresAt); + if (!Number.isFinite(expiresAtMs)) { + return {}; + } + return { expires_in: Math.max(Math.floor((expiresAtMs - Date.now()) / 1000), 0) }; +} + async function handleAuthCodeExchange( req: RouteRequest, body: Record<string, unknown>, @@ -292,9 +310,10 @@ async function handleAuthCodeExchange( codeVerifier: body.code_verifier, redirectUri: body.redirect_uri, }); - return res.json({ + return respondWithTokenJson(res, { access_token: token.access_token, - expires_in: HOSTED_MCP_OAUTH_ACCESS_TOKEN_EXPIRES_IN_SECONDS, + ...(token.authorization_details ? { authorization_details: token.authorization_details } : {}), + ...accessTokenLifetimePayload(token.access_token_expires_at), token_type: token.token_type, ...(token.refresh_token ? { refresh_token: token.refresh_token } : {}), ...buildGrantIdPayload(token), @@ -315,16 +334,23 @@ async function handleRefreshTokenExchange( clientId: body.client_id, refreshToken: body.refresh_token, }); - return res.json({ + return respondWithTokenJson(res, { access_token: token.access_token, - expires_in: HOSTED_MCP_OAUTH_ACCESS_TOKEN_EXPIRES_IN_SECONDS, + ...accessTokenLifetimePayload(token.access_token_expires_at), refresh_token: token.refresh_token, token_type: token.token_type, ...buildGrantIdPayload(token), }); } catch (err) { - const e = err as { code?: string; message?: string }; - return ctx.oauthError(res, 400, e.code ?? "invalid_grant", e.message ?? "Refresh token exchange failed"); + const e = err as { code?: string; fresh_authorization_required?: boolean; message?: string }; + return ctx.oauthError( + res, + 400, + e.code ?? "invalid_grant", + e.message ?? "Refresh token exchange failed", + null, + e.fresh_authorization_required ? { fresh_authorization_required: true } : null + ); } } @@ -359,7 +385,7 @@ export function mountAsToken(app: AppLike, ctx: MountAsTokenContext): void { if (outcome.traceContext?.trace_id) { ctx.setReferenceTraceId(res, String(outcome.traceContext.trace_id)); } - return res.status(outcome.status as number).json(outcome.publicResult); + return respondWithTokenJson(res.status(outcome.status as number), outcome.publicResult); } if (outcome.requestId) { res.setHeader("Request-Id", String(outcome.requestId)); @@ -380,12 +406,57 @@ export function mountAsToken(app: AppLike, ctx: MountAsTokenContext): void { // POST /introspect export interface MountAsIntrospectContext { + authenticateCaller: (authorization: string | undefined) => boolean; /** * Resolves a token's grant/introspection payload. * Delegated to `auth.js#introspect` via context. */ introspect: (token: string) => Promise<AsIntrospectInfo> | AsIntrospectInfo; pdppError: PdppErrorFn; + resolveAudience: () => string | null; + resolveIssuer: () => string | null; +} + +function authenticateIntrospectionRequest( + req: RouteRequest, + res: RouteResponse, + ctx: MountAsIntrospectContext +): boolean { + if (ctx.authenticateCaller(req.get("Authorization"))) { + return true; + } + res.setHeader("WWW-Authenticate", 'Basic realm="introspection"'); + ctx.pdppError(res, 401, "context.authentication_failed", "Introspection client authentication failed"); + return false; +} + +function projectIntrospectionResponse( + info: Record<string, unknown>, + ctx: MountAsIntrospectContext +): Record<string, unknown> { + const issuer = ctx.resolveIssuer(); + const audience = ctx.resolveAudience(); + return { + ...info, + ...(audience ? { aud: audience } : {}), + ...(issuer ? { iss: issuer } : {}), + }; +} + +function respondToIntrospectionOutcome( + res: RouteResponse, + ctx: MountAsIntrospectContext, + outcome: Awaited<ReturnType<typeof executeAsIntrospect>> +): unknown { + if (outcome.outcome === "success") { + return res.json(projectIntrospectionResponse(outcome.publicInfo as Record<string, unknown>, ctx)); + } + return ctx.pdppError( + res, + outcome.status as number, + outcome.errorCode as string, + outcome.errorMessage as string | undefined + ); } export function mountAsIntrospect(app: AppLike, ctx: MountAsIntrospectContext): void { @@ -393,16 +464,14 @@ export function mountAsIntrospect(app: AppLike, ctx: MountAsIntrospectContext): // validation and the AS-internal `grant_storage_binding` redaction live // in the canonical `as.introspect` operation (operations/as-introspect). const handler: RouteHandler = async (req, res) => { - const outcome = await executeAsIntrospect({ token: bodyString(req.body?.token) }, { introspect: ctx.introspect }); - if (outcome.outcome === "success") { - return res.json(outcome.publicInfo); + if (!authenticateIntrospectionRequest(req, res, ctx)) { + return; } - return ctx.pdppError( - res, - outcome.status as number, - outcome.errorCode as string, - outcome.errorMessage as string | undefined + const outcome = await executeAsIntrospect( + { token: bodyString(req.body?.token) }, + { includeStorageBinding: true, introspect: ctx.introspect } ); + return respondToIntrospectionOutcome(res, ctx, outcome); }; app.post("/introspect", { contract: "introspectToken" } as RouteArg<RouteHandler>, handler); } diff --git a/reference-implementation/server/routes/as-par.ts b/reference-implementation/server/routes/as-par.ts index d46ae5809..36e3c1e00 100644 --- a/reference-implementation/server/routes/as-par.ts +++ b/reference-implementation/server/routes/as-par.ts @@ -37,6 +37,9 @@ interface AppLike { post: (path: string, ...args: RouteArg<RouteHandler>[]) => AppLike; } +const SOURCE_AUTHORIZATION_DETAILS_INVALID = "source.authorization_details_invalid"; +const OAUTH_INVALID_AUTHORIZATION_DETAILS = "invalid_authorization_details"; + // ─── Injected capabilities ─────────────────────────────────────────────────── export interface MountAsParContext { @@ -82,9 +85,22 @@ export function mountAsPar(app: AppLike, ctx: MountAsParContext): void { } res.status(output.status).json(output.envelope); } catch (err) { - ctx.handleError(res, err); + ctx.handleError(res, mapParProtocolError(err)); } }; app.post("/oauth/par", { contract: "createPushedAuthorizationRequest" } as RouteArg<RouteHandler>, handler); } + +export function mapParProtocolError(err: unknown): unknown { + if (!err || typeof err !== "object") { + return err; + } + const source = err as { code?: unknown; message?: unknown } & Record<string, unknown>; + if (source.code !== SOURCE_AUTHORIZATION_DETAILS_INVALID) { + return err; + } + const mapped = new Error(typeof source.message === "string" ? source.message : String(err), { cause: err }); + Object.assign(mapped, source, { code: OAUTH_INVALID_AUTHORIZATION_DETAILS }); + return mapped; +} diff --git a/reference-implementation/server/routes/ref-admin.ts b/reference-implementation/server/routes/ref-admin.ts index c085d6c95..2e7c6244f 100644 --- a/reference-implementation/server/routes/ref-admin.ts +++ b/reference-implementation/server/routes/ref-admin.ts @@ -12,6 +12,7 @@ // posture, contract metadata, response envelopes, status codes, error // mapping, and query-string parsing are unchanged. +import { executeRefApprovalDetail, type RefApprovalDetail } from "../../operations/ref-approval-detail/index.ts"; import { executeRefApprovalsList, type RefApproval } from "../../operations/ref-approvals-list/index.ts"; import { executeRefClientTokenRevoke, @@ -100,6 +101,7 @@ export interface MountRefAdminContext { readonly getCimdDocument: (documentId: string) => Promise<RefCimdDocument | null>; // Subject resolution — mirrors `getOwnerSubjectId` closure in index.js. readonly getOwnerSubjectId: (req: RouteRequest) => string; + readonly getPendingApprovalDetail: (approvalId: string) => Promise<RefApprovalDetail | null>; readonly handleError: (res: unknown, err: unknown) => void; readonly listActiveTokensForOwnerClient: ( clientId: string, @@ -324,6 +326,25 @@ export function mountRefApprovals(app: AppLike, ctx: MountRefAdminContext): void } } ); + app.get( + "/_ref/approvals/:approvalId", + { contract: "refGetApproval" }, + ctx.requireOwnerSession, + async (req: RouteRequest, res: RouteResponse) => { + try { + const detail = await executeRefApprovalDetail({ + getPendingApprovalDetail: () => ctx.getPendingApprovalDetail(req.params.approvalId || ""), + }); + if (!detail) { + ctx.pdppError(res, 404, "not_found", "Pending approval not found"); + return; + } + res.json(detail); + } catch (err) { + ctx.handleError(res, err); + } + } + ); } // GET /_ref/records/timeline diff --git a/reference-implementation/server/routes/ref-error-status.ts b/reference-implementation/server/routes/ref-error-status.ts index 8853a26f2..70dcc6826 100644 --- a/reference-implementation/server/routes/ref-error-status.ts +++ b/reference-implementation/server/routes/ref-error-status.ts @@ -93,6 +93,7 @@ export const codeToStatus: Readonly<Record<string, number>> = { ambiguous_connection: 409, ambiguous_connector_instance: 400, ambiguous_schema_detail: 409, + approval_conflict: 409, authentication_error: 401, blob_not_found: 404, browser_enrollment_shell_required: 400, @@ -121,6 +122,7 @@ export const codeToStatus: Readonly<Record<string, number>> = { insufficient_scope: 403, interaction_id_mismatch: 409, invalid_argument: 400, + invalid_authorization_details: 400, invalid_client: 400, invalid_client_metadata: 400, invalid_cursor: 400, @@ -147,6 +149,8 @@ export const codeToStatus: Readonly<Record<string, number>> = { run_owner_mismatch: 403, run_terminal: 503, source_webhook_event_conflict: 409, + "source.authorization_details_invalid": 400, + stream_not_declared: 404, unknown_field: 400, unsupported_version: 400, }; diff --git a/reference-implementation/server/routes/root-and-discovery.ts b/reference-implementation/server/routes/root-and-discovery.ts index 093fba7ac..cbbe02684 100644 --- a/reference-implementation/server/routes/root-and-discovery.ts +++ b/reference-implementation/server/routes/root-and-discovery.ts @@ -293,6 +293,7 @@ export interface MountRsProtectedResourceMetadataContext { | null | Promise<RsProtectedResourceMetadataSemanticCapability | null>; resolveSiblingPublicUrl: (req: unknown, origin: string) => string; + resolveSourceDeclarationUri: () => string | null; shouldUseDirectRequestOrigin: (req: unknown, explicit: unknown) => boolean; trustedMetadataHosts: unknown; } @@ -376,6 +377,7 @@ export function mountRsProtectedResourceMetadata(app: AppLike, ctx: MountRsProte resource, resourceName: `${ctx.providerName} Resource Server`, selfExportSupported: true, + sourceDeclarationUri: ctx.nativeMode ? ctx.resolveSourceDeclarationUri() : null, tokenKindsSupported: ["owner", "client"], }) ); diff --git a/reference-implementation/server/routes/rs-mutation.ts b/reference-implementation/server/routes/rs-mutation.ts index 59ef0b9fd..732f59d28 100644 --- a/reference-implementation/server/routes/rs-mutation.ts +++ b/reference-implementation/server/routes/rs-mutation.ts @@ -258,16 +258,17 @@ interface TokenInfo { readonly client_id?: string | null; readonly grant?: GrantLike | null; readonly grant_id?: string | null; + readonly grant_storage_binding?: { readonly connector_id?: string | null } | null; readonly pdpp_token_kind?: string | null; readonly subject_id?: string | null; readonly [key: string]: unknown; } interface GrantStreamLike { - readonly connection_id?: string | null; + readonly instance_ids?: readonly string[] | null; readonly name?: string | null; readonly resources?: readonly string[] | null; - readonly time_range?: SubscriptionScopeStream["time_range"] | null; + readonly time_constraint?: SubscriptionScopeStream["time_constraint"] | null; readonly [key: string]: unknown; } @@ -631,9 +632,16 @@ export function mountRsBlobsUpload(app: AppLike, ctx: MountRsMutationContext): v // // See: openspec/changes/add-client-event-subscriptions/ -function buildGrantScope(grant: GrantLike): SubscriptionScope { +function buildGrantScope(grant: GrantLike, storageBinding: TokenInfo["grant_storage_binding"]): SubscriptionScope { return { - ...(grant.source ? { source: grant.source } : {}), + ...(grant.source + ? { + source: { + ...grant.source, + ...(storageBinding?.connector_id ? { connector_id: storageBinding.connector_id } : {}), + }, + } + : {}), streams: Array.isArray(grant.streams) ? grant.streams.flatMap((s: GrantStreamLike): SubscriptionScopeStream[] => { if (!s.name) { @@ -642,9 +650,9 @@ function buildGrantScope(grant: GrantLike): SubscriptionScope { return [ { name: s.name, - ...(s.connection_id ? { connection_id: s.connection_id } : {}), + ...(Array.isArray(s.instance_ids) ? { instance_ids: s.instance_ids } : {}), ...(Array.isArray(s.resources) ? { resources: s.resources } : {}), - ...(s.time_range ? { time_range: s.time_range } : {}), + ...(s.time_constraint ? { time_constraint: s.time_constraint } : {}), }, ]; }) @@ -663,7 +671,7 @@ function buildBearerActorFromTokenInfo(req: RouteRequest): BearerActor | null { authorityKind: "client_grant", clientId: ti.client_id, grantId: ti.grant_id, - grantScope: buildGrantScope(grant), + grantScope: buildGrantScope(grant, ti.grant_storage_binding), subjectId: ti.subject_id ?? "", }; } diff --git a/reference-implementation/server/routes/rs-read.ts b/reference-implementation/server/routes/rs-read.ts index 22b8d5714..eed482950 100644 --- a/reference-implementation/server/routes/rs-read.ts +++ b/reference-implementation/server/routes/rs-read.ts @@ -77,6 +77,7 @@ import { type StreamsListDependencies, type StreamsListInput, } from "../../operations/rs-streams-list/index.ts"; +import { rejectUnsupportedClientQuery } from "../record-filters.ts"; import type { MiddlewareHandler, RouteArg } from "./_route-contract.ts"; // Express-shaped surface, structurally typed to avoid pulling in the @@ -119,6 +120,7 @@ interface TokenInfo { } interface GrantStreamLike { + readonly instance_ids?: string[] | null; readonly name?: string | null; readonly [key: string]: unknown; } @@ -175,8 +177,8 @@ interface ResolverWarning { } interface ReadRequestBinding { - readonly connectorId?: string | null; - readonly connectorInstanceId?: string | null; + readonly connectorId: string; + readonly connectorInstanceId: string; readonly displayName?: string | null; readonly [key: string]: unknown; } @@ -189,7 +191,7 @@ interface ReadRequestBindingsResult { } interface NativeManifest { - readonly provider_id?: string | null; + readonly source_declaration?: { readonly source?: SourceDescriptorLike | null } | null; readonly storage_binding?: { connector_id?: string | null } | null; readonly [key: string]: unknown; } @@ -248,6 +250,7 @@ export interface MountRsReadContext { storageBinding: StorageBindingLike; manifest: ManifestLike; grant?: GrantLike | null | undefined; + ownerSubjectId?: string | null | undefined; }) => Promise<unknown>; buildConnectorSchemaItem: (args: { source: SourceDescriptorLike | null; @@ -279,7 +282,7 @@ export interface MountRsReadContext { ensureRequestId: (res: unknown) => string; finalizeCanonicalEnvelope: (payload: unknown, req: unknown) => unknown; getConnectorFreshnessEvidence: (args: { - source: SourceDescriptorLike | null; + storageBinding: StorageBindingLike; manifest: ManifestLike; }) => Promise<unknown>; getOwnerTokenSubjectId: (req: unknown) => string | null; @@ -365,7 +368,7 @@ export interface MountRsReadContext { grant: GrantLike | null; requestParams: Record<string, unknown>; streamName: string | null; - nativeProviderStorage: boolean; + ownerRead?: boolean; }) => Promise<ReadRequestBindingsResult>; resolveRegisteredConnectorManifest: (connectorId: string) => Promise<ManifestLike>; runHybridSearch: (args: Record<string, unknown>) => Promise<{ envelope: unknown; disclosureData: unknown }>; @@ -671,16 +674,14 @@ export function mountRsConnectors(app: AppLike, ctx: MountRsReadContext): void { const nativeManifest = ctx.resolveNativeManifest(ctx.opts); const nativeStorageBinding = ctx.resolveNativeStorageBinding(ctx.opts); if (nativeManifest && nativeStorageBinding) { - const source = ctx.buildSourceDescriptor({ - id: nativeManifest.provider_id, - kind: "provider_native", - }); + const source = ctx.buildSourceDescriptor(nativeManifest.source_declaration?.source); queryContext.sourceDescriptor = source; dependencies = { getSourceDescriptor: () => source, listConnectorItems: async () => { const item = await ctx.buildConnectorDiscoveryItem({ manifest: nativeManifest, + ownerSubjectId: ctx.ownerSubjectIdForBindings(tokenInfo), source, storageBinding: nativeStorageBinding, }); @@ -700,6 +701,7 @@ export function mountRsConnectors(app: AppLike, ctx: MountRsReadContext): void { const manifest = await ctx.resolveRegisteredConnectorManifest(connectorId); return ctx.buildConnectorDiscoveryItem({ manifest, + ownerSubjectId: ctx.ownerSubjectIdForBindings(tokenInfo), source: ctx.buildSourceDescriptor({ id: connectorId, kind: "connector" }), storageBinding: { connector_id: connectorId }, }); @@ -729,6 +731,7 @@ export function mountRsConnectors(app: AppLike, ctx: MountRsReadContext): void { const item = await ctx.buildConnectorDiscoveryItem({ grant: tokenInfo.grant, manifest: grantResolved.manifest, + ownerSubjectId: ctx.ownerSubjectIdForBindings(tokenInfo), source, storageBinding: grantResolved.storageBinding, }); @@ -844,10 +847,7 @@ function buildOwnerSchemaGetPlan( const nativeManifest = ctx.resolveNativeManifest(ctx.opts); const nativeStorageBinding = ctx.resolveNativeStorageBinding(ctx.opts); if (nativeManifest && nativeStorageBinding) { - const source = ctx.buildSourceDescriptor({ - id: nativeManifest.provider_id, - kind: "provider_native", - }); + const source = ctx.buildSourceDescriptor(nativeManifest.source_declaration?.source); queryContext.sourceDescriptor = source; return { dependencies: { @@ -1166,7 +1166,7 @@ async function listOwnerStreamsForConnector( const firstStream = Array.isArray(grant.streams) ? grant.streams[0]?.name : null; const { bindings, warnings: resolverWarnings } = await ctx.resolveReadRequestBindings({ grant, - nativeProviderStorage: false, + ownerRead: true, ownerSubjectId, requestParams, storageBinding: ownerResolved.storageBinding, @@ -1177,7 +1177,7 @@ async function listOwnerStreamsForConnector( resolveBindingsForStream: async (streamGrant: GrantStreamLike) => { const { bindings: streamBindings } = await ctx.resolveReadRequestBindings({ grant, - nativeProviderStorage: false, + ownerRead: true, ownerSubjectId, requestParams, storageBinding: ownerResolved.storageBinding, @@ -1205,7 +1205,7 @@ async function listExplicitPolyfillOwnerStreams( const firstStream = Array.isArray(grant.streams) ? grant.streams[0]?.name : null; const { bindings, warnings: resolverWarnings } = await ctx.resolveReadRequestBindings({ grant, - nativeProviderStorage: false, + ownerRead: true, ownerSubjectId, requestParams, storageBinding: ownerResolved.storageBinding, @@ -1216,7 +1216,7 @@ async function listExplicitPolyfillOwnerStreams( resolveBindingsForStream: async (streamGrant: GrantStreamLike) => { const { bindings: streamBindings } = await ctx.resolveReadRequestBindings({ grant, - nativeProviderStorage: false, + ownerRead: true, ownerSubjectId, requestParams, storageBinding: ownerResolved.storageBinding, @@ -1288,17 +1288,12 @@ async function buildStreamsListOwnerPlan( const ownerResolved = await ctx.resolveOwnerManifest(req, ctx.opts); const streamListFreshnessEvidence = await ctx.getConnectorFreshnessEvidence({ manifest: ownerResolved.manifest, - source: ownerScope.source ?? null, + storageBinding: ownerResolved.storageBinding, }); return { dependencies: { getSourceDescriptor: () => queryContext.sourceDescriptor, - listSummaries: () => { - if (ownerScope.public_scope === "polyfill" || ownerScope.source?.kind === "connector") { - return listExplicitPolyfillOwnerStreams(ctx, req, ownerResolved); - } - return ctx.listAllStreams(ownerResolved.storageBinding); - }, + listSummaries: () => listExplicitPolyfillOwnerStreams(ctx, req, ownerResolved), }, operationInput: { actor: { kind: "owner", subject_id: tokenInfo.subject_id || null }, @@ -1319,51 +1314,44 @@ async function buildStreamsListClientPlan( const grantResolved = await ctx.resolveGrantManifest(tokenInfo, ctx.opts); const streamListFreshnessEvidence = await ctx.getConnectorFreshnessEvidence({ manifest: grantResolved.manifest, - source: grantResolved.source, + storageBinding: grantResolved.storageBinding, }); const streamCountLimit = Array.isArray(grant?.streams) ? grant.streams.length : null; queryContext.sourceDescriptor = grantResolved.source; queryContext.queryData.stream_count_limit = streamCountLimit; const ownerSubjectId = ctx.ownerSubjectIdForBindings(tokenInfo); - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - const nativeProviderStorage = grantResolved.source?.kind === "provider_native"; const requestParams = (req.query as Record<string, unknown>) || {}; return { dependencies: { getSourceDescriptor: () => queryContext.sourceDescriptor, listSummaries: async () => { - // Honor request-time `connection_id` filter and grant-scope - // `connection_id` constraint. When neither is set, fan in across - // every active connection under the grant's connector. - // - // Each grant stream may pin a different `connection_id`; the resolver - // runs per-stream so per-stream record counts honor the right binding - // constraint instead of borrowing the first stream's resolution. - const firstStream = Array.isArray(grant?.streams) ? grant?.streams[0]?.name : null; + // The grant's closed instance_ids are the fan-in upper bound. A + // request-time connection_id may only narrow that set. Resolve each + // stream independently because its authorized instance set may differ. const { bindings, warnings: resolverWarnings } = await ctx.resolveReadRequestBindings({ grant: grant ?? null, - nativeProviderStorage, + ownerRead: tokenInfo.pdpp_token_kind === "owner", ownerSubjectId, requestParams, storageBinding: grantResolved.storageBinding, - streamName: firstStream ?? null, + streamName: null, }); // Stash resolver warnings on the request scope so the route body can // thread them into `meta.warnings` (P3 fix). req._pdpp_resolver_warnings = resolverWarnings; return await ctx.listStreamsAcrossBindings(bindings, grant ?? null, grantResolved.manifest, { - resolveBindingsForStream: async (streamGrant: GrantStreamLike) => { - const { bindings: streamBindings } = await ctx.resolveReadRequestBindings({ - grant: grant ?? null, - nativeProviderStorage, - ownerSubjectId, - requestParams, - storageBinding: grantResolved.storageBinding, - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - streamName: streamGrant?.name || null, - }); - return streamBindings; - }, + resolveBindingsForStream: (streamGrant: GrantStreamLike) => + resolveClientStreamListBindingsOrEmpty(() => + ctx.resolveReadRequestBindings({ + grant: grant ?? null, + ownerRead: tokenInfo.pdpp_token_kind === "owner", + ownerSubjectId, + requestParams, + storageBinding: grantResolved.storageBinding, + // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. + streamName: streamGrant?.name || null, + }) + ), }); }, }, @@ -1381,6 +1369,19 @@ async function buildStreamsListClientPlan( }; } +export async function resolveClientStreamListBindingsOrEmpty( + resolve: () => Promise<{ bindings: ReadRequestBinding[] }> +): Promise<ReadRequestBinding[]> { + try { + return (await resolve()).bindings; + } catch (error) { + if (error instanceof Error && (error as Error & { code?: string }).code === "connection_not_found") { + return []; + } + throw error; + } +} + // GET /v1/streams — list streams (client or owner) export function mountRsStreamsList(app: AppLike, ctx: MountRsReadContext): void { app.get( @@ -1607,7 +1608,7 @@ function buildStreamAggregateDeps( aggregate: async (params: Record<string, unknown>) => { const { bindings, warnings: resolverWarnings } = await ctx.resolveReadRequestBindings({ grant, - nativeProviderStorage: sourceDescriptor?.kind === "provider_native", + ownerRead: tokenInfo.pdpp_token_kind === "owner", ownerSubjectId: ctx.ownerSubjectIdForBindings(tokenInfo), requestParams: params, storageBinding, @@ -1669,6 +1670,8 @@ export function mountRsStreamAggregate(app: AppLike, ctx: MountRsReadContext): v traceId, }; + rejectUnsupportedClientQuery(tokenInfo.pdpp_token_kind, requestParams); + const scope = await resolveReadScope(ctx, req, tokenInfo, queryContext); const { storageBinding, manifest } = scope; // biome-ignore lint/style/useDestructuring: Explicit property or positional access documents this compatibility boundary. @@ -1763,7 +1766,7 @@ function buildRecordsListDeps( queryRecords: async (stream: string, grant: GrantLike | null, params: Record<string, unknown>, m: ManifestLike) => { const { bindings, warnings: resolverWarnings } = await ctx.resolveReadRequestBindings({ grant, - nativeProviderStorage: sourceDescriptor?.kind === "provider_native", + ownerRead: tokenInfo.pdpp_token_kind === "owner", ownerSubjectId: ctx.ownerSubjectIdForBindings(tokenInfo), requestParams: params, storageBinding, @@ -1814,6 +1817,8 @@ export function mountRsRecordsList(app: AppLike, ctx: MountRsReadContext): void traceId, }; + rejectUnsupportedClientQuery(tokenInfo.pdpp_token_kind, requestParams); + // Self-export: owner can query without a client grant. `resolveReadScope` // sets `queryContext.sourceDescriptor` and returns the resolved trio. const { storageBinding, manifest, sourceDescriptor } = await resolveReadScope( @@ -1930,6 +1935,8 @@ export function mountRsRecordDetail(app: AppLike, ctx: MountRsReadContext): void traceId, }; + rejectUnsupportedClientQuery(tokenInfo.pdpp_token_kind, req.query); + ({ storageBinding, manifest, sourceDescriptor } = await resolveReadScope(ctx, req, tokenInfo, queryContext)); await ctx.emitQueryReceived(queryContext, req); @@ -1959,7 +1966,7 @@ export function mountRsRecordDetail(app: AppLike, ctx: MountRsReadContext): void const mergedParams = { ...((req.query as Record<string, unknown>) || {}), ...(options || {}) }; const { bindings, warnings: resolverWarnings } = await ctx.resolveReadRequestBindings({ grant, - nativeProviderStorage: sourceDescriptor?.kind === "provider_native", + ownerRead: tokenInfo.pdpp_token_kind === "owner", ownerSubjectId: ctx.ownerSubjectIdForBindings(tokenInfo), requestParams: mergedParams, storageBinding: storageBinding as StorageBindingLike, @@ -2134,7 +2141,7 @@ export function mountRsRecordFieldWindow(app: AppLike, ctx: MountRsReadContext): const { bindings, warnings: resolverWarnings } = await ctx.resolveReadRequestBindings({ grant, - nativeProviderStorage: sourceDescriptor?.kind === "provider_native", + ownerRead: tokenInfo.pdpp_token_kind === "owner", ownerSubjectId: ctx.ownerSubjectIdForBindings(tokenInfo), requestParams, storageBinding, @@ -2264,6 +2271,7 @@ async function runSearchRouteHandler( tokenInfo, traceId, }; + rejectUnsupportedClientQuery(tokenInfo.pdpp_token_kind, req.query); await ctx.emitQueryReceived(queryContext, req); const { envelope, disclosureData } = await opts.runSearch({ @@ -2362,14 +2370,12 @@ export function mountRsSearchHybrid(app: AppLike, ctx: MountRsReadContext): void interface BlobActorScope { manifest: ManifestLike; - nativeProviderStorage: boolean; storageBinding: StorageBindingLike; } // Owner/client scope resolution for the blob route. Unlike `resolveReadScope`, -// the blob route does not thread `queryContext` (it has no `query.received` -// instrumentation) and needs the `nativeProviderStorage` flag for the binding -// resolver. Behaviour-identical to the previous inline branch. +// the blob route does not thread `queryContext` because it has no +// `query.received` instrumentation. async function resolveBlobActorScope( ctx: MountRsReadContext, req: RouteRequest, @@ -2380,22 +2386,19 @@ async function resolveBlobActorScope( const ownerResolved = await ctx.resolveOwnerManifestFromScope(ownerScope, ctx.opts); return { manifest: ownerResolved.manifest, - nativeProviderStorage: ownerScope.source?.kind === "provider_native", storageBinding: ownerResolved.storageBinding, }; } const grantResolved = await ctx.resolveGrantManifest(tokenInfo, ctx.opts); return { manifest: grantResolved.manifest, - // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. - nativeProviderStorage: grantResolved.source?.kind === "provider_native", storageBinding: grantResolved.storageBinding, }; } // Walk every blob binding and collect the unique connector instances that // expose a visible record referencing this blob. Owns the per-stream -// addressable-id resolution (with its grant-scope `connection_id` re-check and +// addressable-id resolution (with its closed grant `instance_ids` check and // connection_not_found / invalid_argument tolerance) and the visibility scan. // Behaviour-identical to the previous inline loop + `resolveAddressableForStream` // closure. @@ -2408,7 +2411,6 @@ async function scanBlobBindingMatches( blobBindings: BlobBindingRow[]; storageBinding: StorageBindingLike; manifest: ManifestLike; - nativeProviderStorage: boolean; actorConnectorId: string | null; defaultAddressableInstanceIds: Set<string>; } @@ -2420,7 +2422,6 @@ async function scanBlobBindingMatches( blobBindings, storageBinding, manifest, - nativeProviderStorage, actorConnectorId, defaultAddressableInstanceIds, } = args; @@ -2429,9 +2430,8 @@ async function scanBlobBindingMatches( const requestParams = (req.query as Record<string, unknown>) || {}; // Owner-mode addressable cache: owner can read any active connection and - // there is no grant-scope connection_id constraint. Client mode resolves - // `(stream → bindings)` lazily and honors per-stream - // `grant.streams[].connection_id`. + // there is no grant-scoped instance constraint. Client mode resolves + // `(stream → bindings)` lazily from per-stream `grant.streams[].instance_ids`. const streamBindingCache = new Map<string, Set<string>>(); async function resolveAddressableForStream(streamName: string): Promise<Set<string>> { if (ownerMode) { @@ -2446,7 +2446,7 @@ async function scanBlobBindingMatches( try { const { bindings: streamBindings } = await ctx.resolveReadRequestBindings({ grant: tokenInfo.grant || { streams: [] }, - nativeProviderStorage, + ownerRead: ownerMode, ownerSubjectId: ctx.ownerSubjectIdForBindings(tokenInfo), requestParams, storageBinding, @@ -2459,8 +2459,8 @@ async function scanBlobBindingMatches( // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. const code = (err as { code?: string })?.code; if (code === "connection_not_found" || code === "invalid_argument") { - // Grant-scope pins a connection that is not currently active, or the - // request supplied an addressable id outside the grant for this stream. + // The request supplied an addressable id outside the grant for this + // stream, or the stored grant is malformed. // Treat the stream as inaccessible for the blob-visibility check. const empty = new Set<string>(); streamBindingCache.set(streamName, empty); @@ -2471,14 +2471,14 @@ async function scanBlobBindingMatches( } // Is this binding addressable by the caller for its stream? Owner mode and - // the no-grant-scope fan-in case use the default set; grant-scoped clients + // the owner fan-in case use the default set; grant-scoped clients // resolve per-stream. async function bindingIsAddressable(binding: BlobBindingRow): Promise<boolean> { const addressable = grantStreams.length || ownerMode ? await resolveAddressableForStream(binding.stream) : defaultAddressableInstanceIds; - return !(addressable.size > 0 && binding.connector_instance_id && !addressable.has(binding.connector_instance_id)); + return Boolean(binding.connector_instance_id && addressable.has(binding.connector_instance_id)); } // Load the record this binding points at (under the binding's own connection @@ -2608,7 +2608,7 @@ async function serveResolvedBlob( // flow through the `BlobStore` capability (server/stores/blob-store.ts), // constructed once via `ctx.createBlobStore()` at mount time exactly as the // inline `const blobStore = createBlobStore()` did. The route owns the -// binding scan and the per-stream grant-scope `connection_id` re-check (P1/P2 +// binding scan and the per-stream closed `instance_ids` re-check (P1/P2 // fixes); `executeBlobsRead` owns the 404 / 200 shape and error mapping. export function mountRsBlobRead(app: AppLike, ctx: MountRsReadContext): void { const blobStore = ctx.createBlobStore(); @@ -2620,21 +2620,21 @@ export function mountRsBlobRead(app: AppLike, ctx: MountRsReadContext): void { try { const blobId = decodeURIComponent(req.params.blob_id as string); const { tokenInfo } = req; - const { storageBinding, manifest, nativeProviderStorage } = await resolveBlobActorScope(ctx, req, tokenInfo); + const { storageBinding, manifest } = await resolveBlobActorScope(ctx, req, tokenInfo); // Resolve the default set of bindings this caller can address. When // the request supplies `connection_id` (or the deprecated alias) the // resolver narrows; otherwise the resolver fans in. The blob route // does not know the stream yet — that comes from per-binding records // — so we resolve without a stream constraint here and re-check the - // per-stream grant-scope `connection_id` constraint per binding below. + // per-stream closed `instance_ids` constraint per binding below. const { bindings: defaultBindings, requestConnectionId, warnings: resolverWarnings, } = await ctx.resolveReadRequestBindings({ grant: tokenInfo.grant || { streams: [] }, - nativeProviderStorage, + ownerRead: tokenInfo.pdpp_token_kind === "owner", ownerSubjectId: ctx.ownerSubjectIdForBindings(tokenInfo), requestParams: (req.query as Record<string, unknown>) || {}, storageBinding, @@ -2677,7 +2677,6 @@ export function mountRsBlobRead(app: AppLike, ctx: MountRsReadContext): void { blobId, defaultAddressableInstanceIds, manifest, - nativeProviderStorage, req, storageBinding, tokenInfo, diff --git a/reference-implementation/server/schema-capabilities.ts b/reference-implementation/server/schema-capabilities.ts index 60dee2cd0..f5a9e59e6 100644 --- a/reference-implementation/server/schema-capabilities.ts +++ b/reference-implementation/server/schema-capabilities.ts @@ -160,7 +160,8 @@ function buildFieldAggregationCapabilities( function buildFieldCapabilityEntry( field: string, schema: Schema, - ctx: CapabilityContext + ctx: CapabilityContext, + advertiseFilterCapabilities: boolean ): [string, Record<string, unknown>] { const { grantedFields, rangeFilters, fieldDeclarations, lexicalFields, semanticFields, aggregations } = ctx; const granted = !grantedFields || grantedFields.has(field); @@ -191,25 +192,29 @@ function buildFieldCapabilityEntry( ...(declaredType ? { type: declaredType } : {}), ...(declaredRole ? { role: declaredRole } : {}), aggregation: buildFieldAggregationCapabilities(aggregations, field, granted), - exact_filter: buildFieldCapabilityFlag({ - declared: isExactFilterableSchema(schema), - granted, - }), granted, lexical_search: buildFieldCapabilityFlag({ declared: lexicalFields.has(field), granted, }), - range_filter: buildFieldCapabilityFlag({ - declared: Boolean(rangeOperators), - granted, - operators: rangeOperators || undefined, - }), schema, semantic_search: buildFieldCapabilityFlag({ declared: semanticFields.has(field), granted, }), + ...(advertiseFilterCapabilities + ? { + exact_filter: buildFieldCapabilityFlag({ + declared: isExactFilterableSchema(schema), + granted, + }), + range_filter: buildFieldCapabilityFlag({ + declared: Boolean(rangeOperators), + granted, + operators: rangeOperators || undefined, + }), + } + : {}), }, ]; } @@ -258,9 +263,10 @@ export function buildFieldCapabilities( rangeFilters, semanticFields, }; + const advertiseFilterCapabilities = streamGrant === null; return Object.fromEntries( Object.entries(properties).map(([field, schema]) => - buildFieldCapabilityEntry(field, schema, fieldCapabilityContext) + buildFieldCapabilityEntry(field, schema, fieldCapabilityContext, advertiseFilterCapabilities) ) ); } diff --git a/reference-implementation/server/search-semantic.ts b/reference-implementation/server/search-semantic.ts index d00747aa6..f8ab8a721 100644 --- a/reference-implementation/server/search-semantic.ts +++ b/reference-implementation/server/search-semantic.ts @@ -137,12 +137,13 @@ interface CollapsedSemanticHit extends SemanticIndexHit { } interface SemanticGrant extends SearchSemanticGrant { + source?: { id?: string; kind?: string }; streams?: Array<{ name: string; fields?: string[]; - connection_id?: string; + instance_ids?: string[]; resources?: string[]; - time_range?: unknown; + time_constraint?: { field: string; since?: string; until?: string } | null; [key: string]: unknown; }>; subject?: { id?: string }; @@ -3084,7 +3085,10 @@ function compileSingleStreamSearchFilter({ } function hasGrantRecordConstraints(streamGrant: SemanticStreamGrant | null | undefined): boolean { - return !!(streamGrant?.time_range || (Array.isArray(streamGrant?.resources) && streamGrant.resources.length > 0)); + return !!( + streamGrant?.time_constraint || + (Array.isArray(streamGrant?.resources) && streamGrant.resources.length > 0) + ); } function needsCandidateRecordScan( @@ -3231,12 +3235,7 @@ function buildSemanticPlanEntryForGrant({ if (!Array.isArray(declared) || declared.length === 0) { return null; } - if ( - typeof streamGrant.connection_id === "string" && - streamGrant.connection_id.length > 0 && - connectorInstanceId && - streamGrant.connection_id !== connectorInstanceId - ) { + if (connectorInstanceId && !streamGrant.instance_ids?.includes(connectorInstanceId)) { return null; } const grantedFields = @@ -3425,7 +3424,6 @@ export async function runSemanticSearch({ const typedGrant = grant as SemanticGrant; const connectorInstanceId: string | null = (typedManifest.storage_binding as { connector_instance_id?: string } | undefined)?.connector_instance_id || - (typedManifest.connector_id as string | undefined) || null; const compiledFilter = compileSingleStreamSearchFilter({ filter, @@ -3433,11 +3431,21 @@ export async function runSemanticSearch({ manifest: typedManifest, streamName: filteredStream, }); + const bindingScopedGrant = + tokenInfo.pdpp_token_kind === "owner" && connectorInstanceId + ? { + ...typedGrant, + streams: (typedGrant.streams ?? []).map((stream) => ({ + ...stream, + instance_ids: [connectorInstanceId], + })), + } + : typedGrant; return buildSemanticSearchPlanForGrant({ compiledFilter, connectorId, connectorInstanceId, - grant: typedGrant, + grant: bindingScopedGrant, manifest: typedManifest, streamsFilter, }); @@ -3472,27 +3480,24 @@ export async function runSemanticSearch({ const ownerSubjectIdForGrant = (tokenInfo.grant?.subject?.id as string | undefined) || tokenInfo.subject_id || OWNER_AUTH_DEFAULT_SUBJECT_ID; const grantStreams = clientActor?.grant?.streams || []; - let grantStreamConnectionId: string | null = null; - const pinned = grantStreams - .map((s) => s?.connection_id) - .filter((v): v is string => typeof v === "string" && v.length > 0); - if (pinned.length === grantStreams.length && pinned.length > 0) { - const unique = new Set(pinned); - if (unique.size === 1) { - grantStreamConnectionId = pinned[0] ?? null; - } - } + const authorizedInstanceIds = [ + ...new Set( + grantStreams + .flatMap((stream) => stream.instance_ids || []) + .filter((value): value is string => typeof value === "string" && value.length > 0) + ), + ]; const resolveSemanticFanInBindings = resolveFanInBindings as unknown as (args: { + authorizedInstanceIds: string[]; connectorId: string; connectorInstanceIdHint: string | null; - grantStreamConnectionId: string | null; ownerSubjectId: string; requestConnectionId: string | null; }) => Promise<Awaited<ReturnType<typeof resolveFanInBindings>>>; const { bindings } = await resolveSemanticFanInBindings({ + authorizedInstanceIds, connectorId, connectorInstanceIdHint: grantResolved.storageBinding?.connector_instance_id || null, - grantStreamConnectionId: grantStreamConnectionId || null, ownerSubjectId: ownerSubjectIdForGrant, requestConnectionId: connectionId, }); @@ -4081,7 +4086,11 @@ async function persistSemanticSnapshot(snapshot: SearchSemanticSnapshot): Promis // Store backend_hash alongside plan_hash so stale-cursor detection is // deterministic across restarts: the snapshot row is the source of truth // about what backend produced the cached distances. - const planHash = JSON.stringify({ backend: snapshot.backend_hash, plan: snapshot.plan_hash }); + const planHash = JSON.stringify({ + authority: snapshot.authority_key, + backend: snapshot.backend_hash, + plan: snapshot.plan_hash, + }); const resultsJson = JSON.stringify(snapshot.results); await getSemanticSearchStore().persistSnapshot({ @@ -4105,13 +4114,14 @@ function materializeSemanticSnapshot(row: SemanticDbRow | null): SearchSemanticS if (Number.isFinite(createdAt) && Date.now() - createdAt > SNAPSHOT_TTL_MS) { return null; } - let planEnvelope: { backend?: string; plan?: string }; + let planEnvelope: { authority?: string; backend?: string; plan?: string }; try { planEnvelope = JSON.parse(String(row.plan_hash)); } catch { return null; } return { + ...(planEnvelope.authority ? { authority_key: planEnvelope.authority } : {}), backend_hash: planEnvelope.backend ?? "", plan_hash: planEnvelope.plan ?? "", query: String(row.query), diff --git a/reference-implementation/server/search.ts b/reference-implementation/server/search.ts index 7e2e94096..1528000f9 100644 --- a/reference-implementation/server/search.ts +++ b/reference-implementation/server/search.ts @@ -91,11 +91,11 @@ type LexicalManifest = SearchLexicalManifest & { streams: LexicalManifestStream[]; }; interface LexicalGrantStream { - connection_id?: string; fields?: string[]; + instance_ids?: string[]; name: string; resources?: string[]; - time_range?: { since?: string; until?: string } | null; + time_constraint?: { field: string; since?: string; until?: string } | null; [key: string]: unknown; } type LexicalGrant = Omit<SearchLexicalGrant, "streams"> & { streams?: LexicalGrantStream[] }; @@ -207,7 +207,10 @@ interface SearchRequest { } interface SearchTokenInfo { client_id?: string | null; - grant?: LexicalGrant & { subject?: { id?: string | null } }; + grant?: LexicalGrant & { + source?: { id?: string; kind?: string }; + subject?: { id?: string | null }; + }; grant_id?: string | null; pdpp_token_kind: "owner" | "client"; subject_id?: string | null; @@ -1402,11 +1405,21 @@ function createLexicalSearchNativeDependencies({ manifest: typedManifest, streamName: filteredStream, }); + const bindingScopedGrant = + tokenInfo.pdpp_token_kind === "owner" && connectorInstanceId + ? { + ...typedGrant, + streams: (typedGrant.streams ?? []).map((stream) => ({ + ...stream, + instance_ids: [connectorInstanceId], + })), + } + : typedGrant; return buildSearchPlanForGrant({ compiledFilter, connectorId: effectiveConnectorId ?? null, connectorInstanceId, - grant: typedGrant, + grant: bindingScopedGrant, manifest: typedManifest, streamsFilter, }); @@ -1434,33 +1447,19 @@ function createLexicalSearchNativeDependencies({ const connectorId = (baseManifest.storage_binding?.connector_id || baseManifest.connector_id) as string; const ownerSubjectIdForGrant = tokenInfo.grant?.subject?.id || tokenInfo.subject_id || OWNER_AUTH_DEFAULT_SUBJECT_ID; - // Find a representative per-stream grant-scope connection_id if all - // grant streams pin to the same connection. Mixed-constraint grants - // (different per-stream connection_ids) are addressed in the grant - // evaluator via per-stream resolution; the search fan-in passes the - // single pin when all streams agree (or null otherwise). const grantStreams = clientActor?.grant?.streams || []; - let grantStreamConnectionId: string | null = null; - const pinned = grantStreams - .map((s) => s?.connection_id) - .filter((v): v is string => typeof v === "string" && v.length > 0); - if (pinned.length === grantStreams.length && pinned.length > 0) { - const unique = new Set(pinned); - if (unique.size === 1) { - grantStreamConnectionId = pinned[0] as string; - } - } + const authorizedInstanceIds = [...new Set(grantStreams.flatMap((stream) => stream.instance_ids || []))]; const resolveLexicalFanInBindings = resolveFanInBindings as unknown as (args: { + authorizedInstanceIds: string[]; connectorId: string; connectorInstanceIdHint: string | null; - grantStreamConnectionId: string | null; ownerSubjectId: string; requestConnectionId: string | null; }) => Promise<Awaited<ReturnType<typeof resolveFanInBindings>>>; const { bindings } = await resolveLexicalFanInBindings({ + authorizedInstanceIds, connectorId, connectorInstanceIdHint: grantResolved.storageBinding?.connector_instance_id || null, - grantStreamConnectionId, ownerSubjectId: ownerSubjectIdForGrant, requestConnectionId: connectionId, }); @@ -1705,7 +1704,10 @@ function compileSingleStreamSearchFilter({ } function hasGrantRecordConstraints(streamGrant: LexicalGrantStream | null | undefined): boolean { - return !!(streamGrant?.time_range || (Array.isArray(streamGrant?.resources) && streamGrant.resources.length > 0)); + return !!( + streamGrant?.time_constraint || + (Array.isArray(streamGrant?.resources) && streamGrant.resources.length > 0) + ); } function needsCandidateRecordScan( @@ -1746,6 +1748,14 @@ function allowedCandidateRecordKeysFromRows( return allowed; } +export function __filterLexicalCandidateRecordKeysForTest( + rows: Iterable<{ record_json: string | null; record_key: string }>, + streamGrant: LexicalGrantStream, + manifestStream: LexicalManifestStream +): string[] { + return allowedCandidateRecordKeysFromRows(rows, { compiledFilters: [], manifestStream, streamGrant }); +} + async function buildPostgresCandidateRecordKeys({ connectorInstanceId, streamName, @@ -1861,10 +1871,7 @@ function decideSearchPlanStreamEligibility({ return null; } - const hasConnectionPin = typeof streamGrant.connection_id === "string" && streamGrant.connection_id.length > 0; - const isPinnedToAnotherConnection = - hasConnectionPin && resolvedConnectorInstanceId && streamGrant.connection_id !== resolvedConnectorInstanceId; - if (isPinnedToAnotherConnection) { + if (resolvedConnectorInstanceId && !streamGrant.instance_ids?.includes(resolvedConnectorInstanceId)) { return null; } @@ -2756,6 +2763,7 @@ function hashPlan({ */ function serializeSnapshotResultsJson(snapshot: SearchLexicalSnapshot): string { return JSON.stringify({ + ...(snapshot.authority_key ? { authority_key: snapshot.authority_key } : {}), results: snapshot.results, ...(snapshot.recall_meta ? { recall_meta: snapshot.recall_meta } : {}), }); @@ -2795,6 +2803,7 @@ function materializeSnapshot(row: LexicalSnapshotRow | null): SearchLexicalSnaps const results = isWrapped ? parsed.results : parsed; const recallMeta = isWrapped && parsed.recall_meta ? parsed.recall_meta : undefined; return { + ...(isWrapped && typeof parsed.authority_key === "string" ? { authority_key: parsed.authority_key } : {}), plan_hash: row.plan_hash, query: row.query, results, diff --git a/reference-implementation/server/source-approved-authorization.ts b/reference-implementation/server/source-approved-authorization.ts new file mode 100644 index 000000000..2b1fef4f0 --- /dev/null +++ b/reference-implementation/server/source-approved-authorization.ts @@ -0,0 +1,410 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { + ResolvedGrant, + ResolvedGrantStream, + SourceDeclaration, + SourceDeclarationStream, +} from "@pdpp/reference-contract/public/source"; +import { CoreSourceAuthorizationError, parseCoreResolvedGrant } from "./core-source-authorization.ts"; +import { requireSourceDeclaration } from "./source-declaration.ts"; + +type JsonObject = Record<string, unknown>; + +export type ApprovedAuthorizationFailureCode = + | "auth.source_id_empty" + | "auth.access_mode_invalid" + | "auth.streams_empty" + | "auth.stream_name_empty" + | "auth.stream_name_duplicate" + | "auth.instance_ids_empty" + | "auth.instance_id_empty" + | "auth.instance_id_duplicate" + | "auth.fields_empty" + | "auth.field_empty" + | "auth.field_duplicate" + | "auth.time_constraint_invalid" + | "auth.time_field_changed" + | "auth.resources_empty" + | "auth.resource_duplicate" + | "auth.unknown_member" + | "auth.widened"; + +export class ApprovedAuthorizationError extends Error { + readonly code: ApprovedAuthorizationFailureCode; + + constructor(code: ApprovedAuthorizationFailureCode, message: string) { + super(message); + this.code = code; + } +} + +export interface ApprovedAuthorizationStream { + fields: string[]; + instance_ids: string[]; + name: string; + resources?: string[]; + time_constraint?: { field: string; since?: string; until?: string }; +} + +export interface ApprovedAuthorization { + access_mode: "continuous" | "single_use"; + source_id: string; + streams: ApprovedAuthorizationStream[]; +} + +export interface GrantedAuthorizationDetail { + access_mode: "continuous" | "single_use"; + purpose_code: string; + purpose_description?: string; + retention?: { max_duration: string; on_expiry: "anonymize" | "delete" }; + selection_preset?: string; + source: ResolvedGrant["source"]; + streams: ApprovedAuthorizationStream[]; + type: "https://pdpp.dev/data-access"; +} + +const RAR_DETAIL_KEYS = new Set([ + "access_mode", + "purpose_code", + "purpose_description", + "retention", + "selection_preset", + "source", + "streams", + "type", +]); +const STREAM_KEYS = new Set(["fields", "instance_ids", "name", "resources", "time_constraint"]); +const TIME_CONSTRAINT_KEYS = new Set(["field", "since", "until"]); + +function fail(code: ApprovedAuthorizationFailureCode, message: string): never { + throw new ApprovedAuthorizationError(code, message); +} + +function sourceFail(message: string): never { + throw new CoreSourceAuthorizationError(message); +} + +function isObject(value: unknown): value is JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function rejectUnknownKeys(value: JsonObject, allowed: ReadonlySet<string>, context: string): void { + const unknown = Object.keys(value).filter((key) => !allowed.has(key)); + if (unknown.length > 0) { + fail("auth.unknown_member", `${context} has unknown members: ${unknown.join(", ")}`); + } +} + +function requireNonEmptyArray(value: unknown, code: ApprovedAuthorizationFailureCode, label: string): unknown[] { + if (!Array.isArray(value) || value.length === 0) { + fail(code, `${label} must be a non-empty array`); + } + return value; +} + +function requireUniqueStrings( + value: unknown, + codes: { + duplicate: ApprovedAuthorizationFailureCode; + emptyItem: ApprovedAuthorizationFailureCode; + emptyList: ApprovedAuthorizationFailureCode; + }, + label: string +): string[] { + const items = requireNonEmptyArray(value, codes.emptyList, label); + const strings: string[] = []; + for (const item of items) { + if (!isNonEmptyString(item)) { + fail(codes.emptyItem, `${label} entries must be non-empty strings`); + } + strings.push(item); + } + if (new Set(strings).size !== strings.length) { + fail(codes.duplicate, `${label} entries must be unique`); + } + return strings; +} + +function requireCanonicalResources(value: unknown, declarationStream: SourceDeclarationStream): string[] | undefined { + if (value === undefined) { + return; + } + const resources = requireUniqueStrings( + value, + { + duplicate: "auth.resource_duplicate", + emptyItem: "auth.resources_empty", + emptyList: "auth.resources_empty", + }, + "resources" + ); + if (declarationStream.primary_key.length === 1) { + return resources; + } + for (const resource of resources) { + let components: unknown; + try { + components = JSON.parse(resource); + } catch { + fail("auth.resources_empty", "Compound resource identifiers must be minified JSON string arrays"); + } + if ( + !Array.isArray(components) || + components.length !== declarationStream.primary_key.length || + !components.every((component) => typeof component === "string") || + JSON.stringify(components) !== resource + ) { + fail("auth.resources_empty", "Compound resource identifiers must match the declared primary key"); + } + } + return resources; +} + +function requireTimeConstraint( + value: unknown, + declarationStream: SourceDeclarationStream +): ApprovedAuthorizationStream["time_constraint"] { + if (value === undefined) { + return; + } + if (!isObject(value)) { + fail("auth.time_constraint_invalid", "time_constraint must be an object"); + } + rejectUnknownKeys(value, TIME_CONSTRAINT_KEYS, "time_constraint"); + if (!isNonEmptyString(value.field)) { + fail("auth.time_constraint_invalid", "time_constraint.field must be a non-empty string"); + } + if (value.field !== declarationStream.consent_time_field) { + fail("auth.time_field_changed", "time_constraint.field does not match the retained SourceDeclaration"); + } + const { since, until } = value; + if (since === undefined && until === undefined) { + fail("auth.time_constraint_invalid", "time_constraint needs since or until"); + } + for (const bound of [since, until]) { + if (bound !== undefined && !(isNonEmptyString(bound) && Number.isFinite(Date.parse(bound)))) { + fail("auth.time_constraint_invalid", "time_constraint bounds must be ISO-8601 instants"); + } + } + if (isNonEmptyString(since) && isNonEmptyString(until) && Date.parse(since) > Date.parse(until)) { + fail("auth.time_constraint_invalid", "time_constraint.since must not follow until"); + } + return { + field: value.field, + ...(isNonEmptyString(since) ? { since } : {}), + ...(isNonEmptyString(until) ? { until } : {}), + }; +} + +function requireAuthorizationStream(value: unknown, declaration: SourceDeclaration): ApprovedAuthorizationStream { + if (!isObject(value)) { + fail("auth.stream_name_empty", "Each stream must be an object with a non-empty name"); + } + rejectUnknownKeys(value, STREAM_KEYS, "stream"); + if (!isNonEmptyString(value.name)) { + fail("auth.stream_name_empty", "Stream name must be a non-empty string"); + } + const declarationStream = declaration.streams.find((stream) => stream.name === value.name); + if (!declarationStream) { + sourceFail(`Stream '${value.name}' is not present in the retained SourceDeclaration`); + } + const instanceIds = requireUniqueStrings( + value.instance_ids, + { + duplicate: "auth.instance_id_duplicate", + emptyItem: "auth.instance_id_empty", + emptyList: "auth.instance_ids_empty", + }, + `Stream '${value.name}' instance_ids` + ); + const fields = requireUniqueStrings( + value.fields, + { + duplicate: "auth.field_duplicate", + emptyItem: "auth.field_empty", + emptyList: "auth.fields_empty", + }, + `Stream '${value.name}' fields` + ); + const resources = requireCanonicalResources(value.resources, declarationStream); + const timeConstraint = requireTimeConstraint(value.time_constraint, declarationStream); + return { + fields, + instance_ids: instanceIds, + name: value.name, + ...(resources ? { resources } : {}), + ...(timeConstraint ? { time_constraint: timeConstraint } : {}), + }; +} + +function requireAuthorizationRights(value: unknown, declaration: SourceDeclaration): ApprovedAuthorization { + if (!isObject(value)) { + sourceFail("Approved authorization must be an object"); + } + const { source } = value; + const sourceId = isObject(source) ? source.id : null; + if (!isNonEmptyString(sourceId)) { + fail("auth.source_id_empty", "source.id must be a non-empty string"); + } + if (value.access_mode !== "single_use" && value.access_mode !== "continuous") { + fail("auth.access_mode_invalid", "access_mode must be single_use or continuous"); + } + if (!Array.isArray(value.streams) || value.streams.length === 0) { + fail("auth.streams_empty", "streams must be a non-empty array"); + } + const streams = value.streams.map((stream) => requireAuthorizationStream(stream, declaration)); + if (new Set(streams.map((stream) => stream.name)).size !== streams.length) { + fail("auth.stream_name_duplicate", "Stream names must be unique"); + } + return { access_mode: value.access_mode, source_id: sourceId, streams }; +} + +function requireSourceMetadataMatch(value: unknown, declaration: SourceDeclaration): void { + if (!(isObject(value) && isObject(value.source))) { + sourceFail("Approved authorization source metadata is missing"); + } + if (value.source.id !== declaration.source.id || value.source.kind !== declaration.source.kind) { + sourceFail("Approved authorization source metadata does not match the retained SourceDeclaration"); + } +} + +function retainedDeclaration(value: unknown): SourceDeclaration { + try { + return requireSourceDeclaration(value); + } catch (cause: unknown) { + const error = new CoreSourceAuthorizationError("Retained SourceDeclaration is invalid"); + error.cause = cause; + throw error; + } +} + +export function parseResolvedGrantApprovedAuthorization( + value: unknown, + retainedDeclarationInput: unknown +): ApprovedAuthorization { + const declaration = retainedDeclaration(retainedDeclarationInput); + const projected = requireAuthorizationRights(value, declaration); + const grant = parseCoreResolvedGrant(value); + requireSourceMetadataMatch(grant, declaration); + return projected; +} + +function requireGrantedPolicy(value: JsonObject): void { + if (value.type !== "https://pdpp.dev/data-access" || !isNonEmptyString(value.purpose_code)) { + sourceFail("Granted authorization detail has invalid type or purpose_code"); + } + if (value.purpose_description !== undefined && !isNonEmptyString(value.purpose_description)) { + sourceFail("Granted authorization detail has an invalid purpose_description"); + } + if (value.selection_preset !== undefined && !isNonEmptyString(value.selection_preset)) { + sourceFail("Granted authorization detail has an invalid selection_preset"); + } + if (value.retention !== undefined) { + const { retention } = value; + if ( + !isObject(retention) || + Object.keys(retention).some((key) => key !== "max_duration" && key !== "on_expiry") || + !isNonEmptyString(retention.max_duration) || + (retention.on_expiry !== "delete" && retention.on_expiry !== "anonymize") + ) { + sourceFail("Granted authorization detail has invalid retention policy"); + } + } +} + +/** Validate the closed RFC 9396 carrier fields without re-resolving Source metadata. */ +export function requireGrantedAuthorizationDetailEnvelope(value: unknown): JsonObject { + if (!isObject(value)) { + sourceFail("Granted authorization detail must be an object"); + } + rejectUnknownKeys(value, RAR_DETAIL_KEYS, "Granted authorization detail"); + requireGrantedPolicy(value); + return value; +} + +export function parseGrantedAuthorizationDetail( + value: unknown, + retainedDeclarationInput: unknown +): { authorization: ApprovedAuthorization; detail: GrantedAuthorizationDetail } { + const detail = requireGrantedAuthorizationDetailEnvelope(value); + const declaration = retainedDeclaration(retainedDeclarationInput); + const authorization = requireAuthorizationRights(detail, declaration); + requireSourceMetadataMatch(detail, declaration); + return { authorization, detail: structuredClone(detail) as unknown as GrantedAuthorizationDetail }; +} + +export function buildGrantedAuthorizationDetail(value: unknown): GrantedAuthorizationDetail { + const grant = parseCoreResolvedGrant(value); + return { + access_mode: grant.access_mode, + purpose_code: grant.purpose_code, + ...(grant.purpose_description ? { purpose_description: grant.purpose_description } : {}), + ...(grant.retention ? { retention: structuredClone(grant.retention) } : {}), + ...(grant.selection_preset ? { selection_preset: grant.selection_preset } : {}), + source: structuredClone(grant.source), + streams: structuredClone(grant.streams), + type: "https://pdpp.dev/data-access", + }; +} + +function isSubset(actual: readonly string[], ceiling: readonly string[]): boolean { + const allowed = new Set(ceiling); + return actual.every((value) => allowed.has(value)); +} + +function isTimeConstraintNarrower( + actual: ApprovedAuthorizationStream["time_constraint"], + ceiling: ApprovedAuthorizationStream["time_constraint"] +): boolean { + if (!ceiling) { + return true; + } + if (!actual || actual.field !== ceiling.field) { + return false; + } + if (ceiling.since && (!actual.since || Date.parse(actual.since) < Date.parse(ceiling.since))) { + return false; + } + if (ceiling.until && (!actual.until || Date.parse(actual.until) > Date.parse(ceiling.until))) { + return false; + } + return true; +} + +export function requireApprovedAuthorizationNarrowing( + actual: ApprovedAuthorization, + ceiling: ApprovedAuthorization +): void { + if (actual.source_id !== ceiling.source_id || actual.access_mode !== ceiling.access_mode) { + fail("auth.widened", "Approved authorization changed its source or access mode"); + } + const ceilingStreams = new Map(ceiling.streams.map((stream) => [stream.name, stream])); + for (const stream of actual.streams) { + const limit = ceilingStreams.get(stream.name); + if ( + !(limit && isSubset(stream.instance_ids, limit.instance_ids) && isSubset(stream.fields, limit.fields)) || + (limit.resources && !(stream.resources && isSubset(stream.resources, limit.resources))) || + !isTimeConstraintNarrower(stream.time_constraint, limit.time_constraint) + ) { + fail("auth.widened", `Approved stream '${stream.name}' exceeds the requested authorization`); + } + } +} + +export function approvedAuthorizationFromStreams( + sourceId: string, + accessMode: ApprovedAuthorization["access_mode"], + streams: readonly ResolvedGrantStream[] +): ApprovedAuthorization { + return { + access_mode: accessMode, + source_id: sourceId, + streams: streams.map((stream) => structuredClone(stream)), + }; +} diff --git a/reference-implementation/server/source-declaration-legacy-collection.ts b/reference-implementation/server/source-declaration-legacy-collection.ts new file mode 100644 index 000000000..74da3aff6 --- /dev/null +++ b/reference-implementation/server/source-declaration-legacy-collection.ts @@ -0,0 +1,199 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import type { SourceDeclaration, SourceDeclarationStream } from "@pdpp/reference-contract/public/source"; +import { + InvalidSourceDeclarationError, + requireSourceDeclaration, + snapshotSourceDeclaration, +} from "./source-declaration.ts"; + +export const COLLECTION_PROFILE_URI = "https://pdpp.org/profile/collection"; +export const LEGACY_CONNECTOR_PROJECTION_VERSION_PREFIX = "reference.legacy-connector-projection.v1"; + +export interface LegacyConnectorDeclarationAttribution { + connectorImplementationId?: string; + declarationVersion: string; + publisherId: string; + sourceId: string; +} + +type JsonObject = Record<string, unknown>; + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (isObject(value)) { + const members = Object.keys(value) + .filter((key) => value[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`); + return `{${members.join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +function isObject(value: unknown): value is JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requireNonEmptyString(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new InvalidSourceDeclarationError(`${field} must be a non-empty string`); + } + return value; +} + +function requireAbsoluteUri(value: unknown, field: string): string { + const uri = requireNonEmptyString(value, field); + if (!URL.canParse(uri)) { + throw new InvalidSourceDeclarationError(`${field} must be an absolute URI`); + } + return uri; +} + +function cloneJson<T>(value: T): T { + return structuredClone(value); +} + +function requireLegacyStreams(manifest: JsonObject): JsonObject[] { + if (!(Array.isArray(manifest.streams) && manifest.streams.length > 0)) { + throw new InvalidSourceDeclarationError("manifest.streams must be a non-empty array"); + } + const malformedIndex = manifest.streams.findIndex((stream) => !isObject(stream)); + if (malformedIndex !== -1) { + throw new InvalidSourceDeclarationError(`manifest.streams[${malformedIndex}] must be an object`); + } + return manifest.streams as JsonObject[]; +} + +function sourceStreamFromLegacy(stream: JsonObject): SourceDeclarationStream { + const commonKeys = [ + "consent_time_field", + "cursor_field", + "description", + "display", + "name", + "primary_key", + "query", + "relationships", + "schema", + "semantics", + "views", + ] as const; + const commonStream: JsonObject = {}; + for (const key of commonKeys) { + if (stream[key] !== undefined) { + commonStream[key] = cloneJson(stream[key]); + } + } + if (commonStream.semantics === "append") { + commonStream.semantics = "append_only"; + } + const legacySelection = isObject(stream.selection) ? stream.selection : {}; + commonStream.selection = { + fields: legacySelection.fields, + resources: legacySelection.resources, + }; + return commonStream as unknown as SourceDeclarationStream; +} + +function collectionExtensionFromLegacy( + manifest: JsonObject, + streams: JsonObject[], + connectorImplementationId: string, + connectorVersion: string +): JsonObject { + const runtimeRequirements = isObject(manifest.runtime_requirements) ? manifest.runtime_requirements : undefined; + const extension: JsonObject = { + connector: { id: connectorImplementationId, version: connectorVersion }, + runtime_requirements: { + bindings: cloneJson(runtimeRequirements?.bindings ?? {}), + }, + }; + const capabilities = isObject(manifest.capabilities) ? manifest.capabilities : undefined; + if (capabilities?.human_interaction !== undefined) { + extension.capabilities = { human_interaction: cloneJson(capabilities.human_interaction) }; + } + const incrementalStreams = streams + .filter((stream) => typeof stream.incremental === "boolean") + .map((stream) => ({ incremental: stream.incremental, name: stream.name })); + if (incrementalStreams.length > 0) { + extension.streams = incrementalStreams; + } + return extension; +} + +/** + * Compatibility projection only. The legacy manifest remains authoritative + * for reference-implementation setup and operational metadata. + */ +export function sourceDeclarationFromLegacyConnectorManifest( + manifest: JsonObject, + attribution: LegacyConnectorDeclarationAttribution +): SourceDeclaration { + const sourceId = requireAbsoluteUri(attribution.sourceId, "sourceId"); + const publisherId = requireAbsoluteUri(attribution.publisherId, "publisherId"); + const declarationVersion = requireNonEmptyString(attribution.declarationVersion, "declarationVersion"); + const connectorVersion = requireNonEmptyString(manifest.version, "manifest.version"); + const connectorImplementationId = requireAbsoluteUri( + attribution.connectorImplementationId ?? manifest.connector_id, + "connectorImplementationId" + ); + const streams = requireLegacyStreams(manifest); + const protocolVersion = requireNonEmptyString(manifest.protocol_version, "manifest.protocol_version"); + if (protocolVersion !== "0.1.0") { + throw new InvalidSourceDeclarationError(`manifest.protocol_version must be 0.1.0, received ${protocolVersion}`); + } + const declaration: SourceDeclaration = { + declaration_version: declarationVersion, + display: { name: requireNonEmptyString(manifest.display_name, "manifest.display_name") }, + extensions: { + [COLLECTION_PROFILE_URI]: collectionExtensionFromLegacy( + manifest, + streams, + connectorImplementationId, + connectorVersion + ), + }, + protocol_version: protocolVersion, + publisher: { id: publisherId }, + source: { id: sourceId, kind: "connector" }, + streams: streams.map(sourceStreamFromLegacy), + }; + if (Array.isArray(manifest.profiles)) { + declaration.selection_presets = cloneJson(manifest.profiles) as NonNullable<SourceDeclaration["selection_presets"]>; + } + return requireSourceDeclaration(declaration); +} + +/** Project a legacy manifest into one detached immutable declaration value. */ +export function snapshotSourceDeclarationFromLegacyConnectorManifest( + manifest: JsonObject, + attribution: LegacyConnectorDeclarationAttribution +): SourceDeclaration { + return snapshotSourceDeclaration(sourceDeclarationFromLegacyConnectorManifest(manifest, attribution)); +} + +/** + * Project a legacy connector manifest and identify the exact normalized + * declaration content. The version excludes its own field from the digest so + * the identity is deterministic and non-circular. + */ +export function snapshotContentAddressedSourceDeclarationFromLegacyConnectorManifest( + manifest: JsonObject, + attribution: Omit<LegacyConnectorDeclarationAttribution, "declarationVersion"> +): SourceDeclaration { + const provisional = sourceDeclarationFromLegacyConnectorManifest(manifest, { + ...attribution, + declarationVersion: `${LEGACY_CONNECTOR_PROJECTION_VERSION_PREFIX}:pending`, + }); + const { declaration_version: _declarationVersion, ...content } = provisional; + const digest = createHash("sha256").update(stableJson(content)).digest("hex"); + return snapshotSourceDeclaration({ + ...provisional, + declaration_version: `${LEGACY_CONNECTOR_PROJECTION_VERSION_PREFIX}:sha256:${digest}`, + }); +} diff --git a/reference-implementation/server/source-declaration-trust/live-retrieval.ts b/reference-implementation/server/source-declaration-trust/live-retrieval.ts new file mode 100644 index 000000000..166e9c0b4 --- /dev/null +++ b/reference-implementation/server/source-declaration-trust/live-retrieval.ts @@ -0,0 +1,205 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Production network adapter for bounded declaration retrieval. + * + * `retrieval.ts` owns URL, DNS, address-policy, redirect, time, and byte + * bounds. This adapter makes its validated-address fetch contract real: every + * request receives a fresh undici dispatcher which dials only those literal + * addresses. The URL hostname remains intact, so HTTP authority, TLS SNI, and + * certificate hostname verification still authenticate the declaration URL. + */ + +import { lookup as dnsLookup } from "node:dns/promises"; +import { fetch as undiciFetch } from "undici"; +import { createPinnedDispatcher, MAX_VALIDATED_ADDRESSES } from "../ssrf-guard.ts"; +import { + type DeclarationFetchRequest, + type DeclarationResponse, + DeclarationResponseTooLargeError, + type DeclarationRetrievalDependencies, +} from "./retrieval.ts"; + +type LiveFetchResponse = Pick<Awaited<ReturnType<typeof undiciFetch>>, "body" | "headers" | "status">; +interface PinnedDispatcher { + readonly close: () => Promise<void>; +} + +interface LiveFetchInit { + readonly credentials: "omit"; + readonly dispatcher: PinnedDispatcher; + readonly method: "GET"; + readonly redirect: "manual"; + readonly signal: AbortSignal; +} +type LiveFetch = (input: string, init: LiveFetchInit) => Promise<LiveFetchResponse>; + +type PinnedDispatcherFactory = (validatedAddresses: readonly string[]) => PinnedDispatcher; + +export type DeclarationDnsLookup = ( + hostname: string, + options: { all: true } +) => Promise<ReadonlyArray<{ readonly address: string }>>; + +export interface LiveDeclarationRetrievalOptions + extends Pick<DeclarationRetrievalDependencies, "allowsUrl" | "validateAddress" | "validateDeclaration"> { + /** Defaults to `node:dns` with `{ all: true }`; address policy stays injected. */ + readonly dnsLookupImpl?: DeclarationDnsLookup; + /** Test seam for the concrete HTTP client; production defaults to undici. */ + readonly fetchImpl?: LiveFetch; + /** Test seam for the shared DNS-rebinding-safe undici dispatcher. */ + readonly pinnedDispatcherFactory?: PinnedDispatcherFactory; +} + +function defaultDnsLookup( + hostname: string, + options: { all: true } +): Promise<ReadonlyArray<{ readonly address: string }>> { + return dnsLookup(hostname, options); +} + +function headersToRecord(headers: Headers): Readonly<Record<string, string>> { + return Object.fromEntries(headers.entries()); +} + +function emptyBody(): ReadableStream<Uint8Array> { + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }); +} + +function closeDispatcher(dispatcher: PinnedDispatcher): void { + // Closing releases idle resources after EOF/cancellation. Its result must + // not become a retrieval outcome: the response stream is already complete + // (or has failed) by the time this runs. + dispatcher.close().catch(() => { + // Best effort teardown only. + }); +} + +function closeDispatcherWhenBodySettles( + body: ReadableStream<Uint8Array>, + dispatcher: PinnedDispatcher, + maxBytes: number +): ReadableStream<Uint8Array> { + const reader = body.getReader(); + let settled = false; + let totalBytes = 0; + const finish = () => { + if (!settled) { + settled = true; + reader.releaseLock(); + closeDispatcher(dispatcher); + } + }; + + return new ReadableStream({ + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + finish(); + } + }, + async pull(controller) { + try { + const next = await reader.read(); + if (next.done) { + controller.close(); + finish(); + return; + } + totalBytes += next.value.byteLength; + if (totalBytes > maxBytes) { + try { + await reader.cancel("declaration response exceeds configured bounds"); + } catch { + // The byte bound wins if a peer-side failure races cancellation. + } + finish(); + controller.error(new DeclarationResponseTooLargeError()); + return; + } + controller.enqueue(next.value); + } catch (error) { + controller.error(error); + finish(); + } + }, + }); +} + +/** + * Creates the concrete fetch operation used by declaration retrieval. + * + * It refuses an empty or oversized connector set even though the retriever + * already rejects those answers. This prevents direct misuse of the exported + * transport seam from silently truncating `createPinnedDispatcher`'s bounded + * fallback list. It deliberately does not apply the SSRF guard's global + * unicast policy: callers inject their own `validateAddress` policy before + * this function is called, including any sanctioned local-development rule. + */ +export function createPinnedDeclarationFetch({ + fetchImpl = (input, init) => undiciFetch(input, init as Parameters<typeof undiciFetch>[1]), + pinnedDispatcherFactory = createPinnedDispatcher, +}: Pick<LiveDeclarationRetrievalOptions, "fetchImpl" | "pinnedDispatcherFactory"> = {}): ( + request: DeclarationFetchRequest +) => Promise<DeclarationResponse> { + return async (request) => { + if (request.validatedAddresses.length === 0 || request.validatedAddresses.length > MAX_VALIDATED_ADDRESSES) { + throw new Error("declaration request requires a non-empty, bounded validated address set"); + } + const dispatcher = pinnedDispatcherFactory(request.validatedAddresses); + let response: LiveFetchResponse; + try { + response = await fetchImpl(request.url, { + credentials: request.credentials, + dispatcher, + method: "GET", + redirect: request.redirect, + signal: request.signal, + }); + } catch (error) { + closeDispatcher(dispatcher); + throw error; + } + if (!response.body) { + closeDispatcher(dispatcher); + return { body: emptyBody(), headers: headersToRecord(response.headers), status: response.status }; + } + return { + body: closeDispatcherWhenBodySettles(response.body, dispatcher, request.maxBytes), + headers: headersToRecord(response.headers), + status: response.status, + }; + }; +} + +/** + * Composes the live DNS and pinned-socket transport with caller-owned policy + * and Source-contract validation. The retrieval core invokes `resolveDns` on + * every redirect hop, then passes only that hop's accepted addresses to the + * concrete fetch above. + */ +export function createLiveDeclarationRetrievalDependencies({ + allowsUrl, + dnsLookupImpl = defaultDnsLookup, + fetchImpl, + pinnedDispatcherFactory, + validateAddress, + validateDeclaration, +}: LiveDeclarationRetrievalOptions): DeclarationRetrievalDependencies { + return { + ...(allowsUrl ? { allowsUrl } : {}), + fetch: createPinnedDeclarationFetch({ + ...(fetchImpl ? { fetchImpl } : {}), + ...(pinnedDispatcherFactory ? { pinnedDispatcherFactory } : {}), + }), + resolveDns: async (hostname) => (await dnsLookupImpl(hostname, { all: true })).map(({ address }) => address), + validateAddress, + validateDeclaration, + }; +} diff --git a/reference-implementation/server/source-declaration-trust/retrieval.ts b/reference-implementation/server/source-declaration-trust/retrieval.ts new file mode 100644 index 000000000..bfa452b95 --- /dev/null +++ b/reference-implementation/server/source-declaration-trust/retrieval.ts @@ -0,0 +1,449 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Credential-free, bounded declaration retrieval. + * + * This module deliberately does not import a network client, DNS resolver, or + * SourceDeclaration schema. The caller injects each effect and the existing + * Source contract validator. That leaves one small policy boundary to test: + * each hop resolves and validates its own addresses before a credential-free + * request is allowed to connect to one of them. + */ + +export interface RetrievedDeclaration { + readonly declaration: { + readonly declaration_version: string; + readonly source: { readonly id: string; readonly kind: string }; + readonly [member: string]: unknown; + }; + readonly finalUrl: string; +} + +export type DeclarationRetrievalFailure = + | "address_rejected" + | "body_too_large" + | "declaration_invalid" + | "http_error" + | "invalid_declaration_url" + | "invalid_redirect" + | "redirect_limit" + | "source_mismatch" + | "timeout"; + +export type DeclarationRetrievalResult = + | { readonly ok: true; readonly value: RetrievedDeclaration } + | { readonly ok: false; readonly reason: DeclarationRetrievalFailure }; + +/** Raised by a live HTTP adapter that stops a response before it exceeds its byte contract. */ +export class DeclarationResponseTooLargeError extends Error { + constructor() { + super("declaration response exceeds configured bounds"); + this.name = "DeclarationResponseTooLargeError"; + } +} + +export interface DeclarationResponse { + /** A fresh response stream. The retriever reads and cancels it at `maxBytes`. */ + readonly body: ReadableStream<Uint8Array>; + readonly headers?: Readonly<Record<string, string | undefined>>; + readonly status: number; +} + +export interface DeclarationFetchRequest { + /** Retrieval never forwards cookies, bearer tokens, or other ambient credentials. */ + readonly credentials: "omit"; + /** Whole-response byte limit which the HTTP adapter must also enforce while streaming. */ + readonly maxBytes: number; + /** Never let the injected HTTP client follow an unvalidated redirect itself. */ + readonly redirect: "manual"; + readonly signal: AbortSignal; + /** The URL whose authority TLS must authenticate. */ + readonly url: string; + /** DNS answers just resolved and accepted for this exact connection attempt. */ + readonly validatedAddresses: readonly string[]; +} + +export interface DeclarationRetrievalDependencies { + /** + * The initial pointer and every target must be explicitly accepted. The + * default policy below permits only the exact accepted pointer; redirects + * are therefore denied unless a caller supplies a narrower local policy. + */ + readonly allowsUrl?: (input: { + acceptedPointer: string; + fromUrl: string | null; + targetUrl: string; + }) => boolean | Promise<boolean>; + readonly fetch: (request: DeclarationFetchRequest) => Promise<DeclarationResponse>; + readonly resolveDns: (hostname: string) => Promise<readonly string[]>; + readonly validateAddress: (input: { address: string; hostname: string; url: string }) => boolean | Promise<boolean>; + /** + * Validate against the Source Declaration contract already owned by + * `@pdpp/reference-contract`. It must not retrieve remote schemas. + */ + readonly validateDeclaration: ( + value: unknown + ) => { readonly ok: true; readonly declaration: RetrievedDeclaration["declaration"] } | { readonly ok: false }; +} + +export interface DeclarationRetrievalPolicy { + readonly maxAddresses: number; + readonly maxBytes: number; + readonly maxRedirects: number; + readonly timeoutMs: number; +} + +const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]); +const MAX_SUPPORTED_ADDRESSES = 16; +const MAX_SUPPORTED_REDIRECTS = 16; + +type HopValidationResult = + | { readonly ok: true; readonly validatedAddresses: readonly string[] } + | { + readonly ok: false; + readonly reason: "address_rejected" | "invalid_declaration_url" | "invalid_redirect" | "timeout"; + }; + +const DEADLINE_EXCEEDED = new Error("Declaration retrieval deadline exceeded."); + +function hasSafeHttpsAuthority(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" && parsed.hash === "" && parsed.username === "" && parsed.password === ""; + } catch { + return false; + } +} + +function readHeader(headers: DeclarationResponse["headers"], name: string): string | null { + if (!headers) { + return null; + } + const wanted = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === wanted && typeof value === "string") { + return value; + } + } + return null; +} + +function parseJson(body: Uint8Array): unknown | null { + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); + } catch { + return null; + } +} + +async function discardResponseBody(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> { + try { + await beforeDeadline(signal, body.cancel("declaration response is not accepted")); + } catch { + // A peer may already have failed the stream. The response is discarded in + // either case; its cancellation failure must not escape the fail-closed API. + } +} + +function discardLateResponseBody(body: ReadableStream<Uint8Array>): void { + try { + body.cancel("declaration response arrived after the retrieval deadline").catch(() => { + // The late response is no longer observable by the caller. Best-effort + // cancellation still releases a live adapter's pinned dispatcher. + }); + } catch { + // A non-conforming injected stream must not turn a timed-out retrieval + // into an unhandled exception. + } +} + +async function readBoundedBody( + body: ReadableStream<Uint8Array>, + maxBytes: number, + signal: AbortSignal +): Promise<Uint8Array | null> { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + // biome-ignore lint/performance/noAwaitInLoops: each read depends on the prior stream chunk; byte and time bounds cap retained work. + const next = await beforeDeadline(signal, reader.read()); + if (next.done) { + return joinChunks(chunks, total); + } + total += next.value.byteLength; + if (total > maxBytes) { + try { + await beforeDeadline(signal, reader.cancel("declaration response exceeds configured bounds")); + } catch { + // The configured response bound still wins when cancellation races a + // peer-side failure; callers receive body_too_large rather than a throw. + } + return null; + } + if (next.value.byteLength !== 0) { + chunks.push(next.value); + } + } + } catch (error) { + try { + await beforeDeadline(signal, reader.cancel("declaration response read failed")); + } catch { + // The typed retrieval failure still wins when peer cancellation races a + // read failure or ignores the abort signal. + } + throw error; + } finally { + reader.releaseLock(); + } +} + +function joinChunks(chunks: readonly Uint8Array[], total: number): Uint8Array { + const result = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} + +function boundedTimeout(timeoutMs: number): AbortSignal | null { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + return null; + } + return AbortSignal.timeout(timeoutMs); +} + +function defaultAllowsUrl(acceptedPointer: string, targetUrl: string): boolean { + return acceptedPointer === targetUrl; +} + +function beforeDeadline<T>(signal: AbortSignal, operation: Promise<T>): Promise<T> { + return new Promise((resolve, reject) => { + const finish = () => { + signal.removeEventListener("abort", abort); + }; + const abort = () => { + finish(); + reject(DEADLINE_EXCEEDED); + }; + if (signal.aborted) { + abort(); + return; + } + signal.addEventListener("abort", abort, { once: true }); + operation.then( + (value) => { + finish(); + resolve(value); + }, + (error: unknown) => { + finish(); + reject(error instanceof Error ? error : new Error(String(error))); + } + ); + }); +} + +async function validateHop( + currentUrl: string, + previousUrl: string | null, + input: { readonly acceptedPointer: string }, + policy: Pick<DeclarationRetrievalPolicy, "maxAddresses">, + dependencies: DeclarationRetrievalDependencies, + signal: AbortSignal +): Promise<HopValidationResult> { + if (!hasSafeHttpsAuthority(currentUrl)) { + return { ok: false, reason: "invalid_declaration_url" }; + } + let allowed: boolean; + try { + allowed = dependencies.allowsUrl + ? await beforeDeadline( + signal, + Promise.resolve( + dependencies.allowsUrl({ + acceptedPointer: input.acceptedPointer, + fromUrl: previousUrl, + targetUrl: currentUrl, + }) + ) + ) + : defaultAllowsUrl(input.acceptedPointer, currentUrl); + } catch (error) { + return { ok: false, reason: error === DEADLINE_EXCEEDED ? "timeout" : "invalid_redirect" }; + } + if (!allowed) { + return { ok: false, reason: previousUrl ? "invalid_redirect" : "invalid_declaration_url" }; + } + const { hostname } = new URL(currentUrl); + try { + const resolved = await beforeDeadline(signal, dependencies.resolveDns(hostname)); + if (resolved.length === 0 || resolved.length > policy.maxAddresses) { + return { ok: false, reason: "address_rejected" }; + } + const accepted = await beforeDeadline( + signal, + Promise.all( + resolved.map(async (address) => ({ + accepted: await dependencies.validateAddress({ address, hostname, url: currentUrl }), + address, + })) + ) + ); + if (accepted.some(({ accepted: acceptedAddress }) => !acceptedAddress)) { + return { ok: false, reason: "address_rejected" }; + } + return { ok: true, validatedAddresses: accepted.map(({ address }) => address) }; + } catch (error) { + return { ok: false, reason: error === DEADLINE_EXCEEDED ? "timeout" : "address_rejected" }; + } +} + +function redirectTarget(response: DeclarationResponse, currentUrl: string): string | null { + const location = readHeader(response.headers, "location"); + if (!location) { + return null; + } + try { + const targetUrl = new URL(location, currentUrl).toString(); + return hasSafeHttpsAuthority(targetUrl) ? targetUrl : null; + } catch { + return null; + } +} + +async function parseAcceptedResponse( + response: DeclarationResponse, + currentUrl: string, + expectedSourceId: string, + maxBytes: number, + dependencies: DeclarationRetrievalDependencies, + signal: AbortSignal +): Promise<DeclarationRetrievalResult> { + let body: Uint8Array | null; + try { + body = await readBoundedBody(response.body, maxBytes, signal); + } catch (error) { + if (error instanceof DeclarationResponseTooLargeError) { + return { ok: false, reason: "body_too_large" }; + } + if (error === DEADLINE_EXCEEDED) { + return { ok: false, reason: "timeout" }; + } + return { + ok: false, + reason: "http_error", + }; + } + if (body === null) { + return { ok: false, reason: "body_too_large" }; + } + const parsed = parseJson(body); + let validated: ReturnType<DeclarationRetrievalDependencies["validateDeclaration"]>; + try { + validated = parsed === null ? { ok: false } : dependencies.validateDeclaration(parsed); + } catch { + return { ok: false, reason: "declaration_invalid" }; + } + if (!validated.ok) { + return { ok: false, reason: "declaration_invalid" }; + } + if (validated.declaration.source.id !== expectedSourceId) { + return { ok: false, reason: "source_mismatch" }; + } + return { ok: true, value: { declaration: validated.declaration, finalUrl: currentUrl } }; +} + +async function retrieveHop( + currentUrl: string, + previousUrl: string | null, + redirects: number, + input: { readonly acceptedPointer: string; readonly expectedSourceId: string }, + policy: DeclarationRetrievalPolicy, + dependencies: DeclarationRetrievalDependencies, + signal: AbortSignal +): Promise<DeclarationRetrievalResult> { + if (signal.aborted) { + return { ok: false, reason: "timeout" }; + } + const hop = await validateHop(currentUrl, previousUrl, input, policy, dependencies, signal); + if (!hop.ok) { + return hop; + } + let fetchOperation: Promise<DeclarationResponse> | null = null; + let response: DeclarationResponse; + try { + fetchOperation = dependencies.fetch({ + credentials: "omit", + maxBytes: policy.maxBytes, + redirect: "manual", + signal, + url: currentUrl, + validatedAddresses: hop.validatedAddresses, + }); + response = await beforeDeadline(signal, fetchOperation); + } catch (error) { + if (error === DEADLINE_EXCEEDED && fetchOperation) { + fetchOperation.then( + (lateResponse) => discardLateResponseBody(lateResponse.body), + () => { + // The timeout result already owns this failure path. + } + ); + } + return { ok: false, reason: error === DEADLINE_EXCEEDED ? "timeout" : "http_error" }; + } + if (signal.aborted) { + await discardResponseBody(response.body, signal); + return { ok: false, reason: "timeout" }; + } + if (REDIRECT_STATUS.has(response.status)) { + await discardResponseBody(response.body, signal); + if (redirects >= policy.maxRedirects) { + return { ok: false, reason: "redirect_limit" }; + } + const targetUrl = redirectTarget(response, currentUrl); + if (!targetUrl) { + return { ok: false, reason: "invalid_redirect" }; + } + return retrieveHop(targetUrl, currentUrl, redirects + 1, input, policy, dependencies, signal); + } + if (response.status < 200 || response.status >= 300) { + await discardResponseBody(response.body, signal); + return { ok: false, reason: "http_error" }; + } + return parseAcceptedResponse(response, currentUrl, input.expectedSourceId, policy.maxBytes, dependencies, signal); +} + +/** + * Fetch and validate one declaration without using ambient process networking + * or source-schema state. A failure never returns partially parsed content. + */ +export function retrieveSourceDeclaration( + input: { readonly acceptedPointer: string; readonly expectedSourceId: string }, + policy: DeclarationRetrievalPolicy, + dependencies: DeclarationRetrievalDependencies +): Promise<DeclarationRetrievalResult> { + if ( + !(hasSafeHttpsAuthority(input.acceptedPointer) && Number.isSafeInteger(policy.maxBytes)) || + policy.maxBytes <= 0 || + !Number.isSafeInteger(policy.maxRedirects) || + policy.maxRedirects < 0 || + policy.maxRedirects > MAX_SUPPORTED_REDIRECTS || + !Number.isSafeInteger(policy.maxAddresses) || + policy.maxAddresses < 1 || + policy.maxAddresses > MAX_SUPPORTED_ADDRESSES + ) { + return Promise.resolve({ ok: false, reason: "invalid_declaration_url" } as const); + } + + const signal = boundedTimeout(policy.timeoutMs); + if (!signal) { + return Promise.resolve({ ok: false, reason: "timeout" } as const); + } + return retrieveHop(input.acceptedPointer, null, 0, input, policy, dependencies, signal); +} diff --git a/reference-implementation/server/source-declaration-trust/revision-store.ts b/reference-implementation/server/source-declaration-trust/revision-store.ts new file mode 100644 index 000000000..f2c31a4a0 --- /dev/null +++ b/reference-implementation/server/source-declaration-trust/revision-store.ts @@ -0,0 +1,388 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** Immutable accepted declaration revisions, separate from grants and consent. */ + +import { createHash } from "node:crypto"; + +export interface AcceptedRevisionKey { + readonly authorityBinding: string; + readonly declarationVersion: string; + readonly sourceId: string; +} + +export interface AcceptedRevisionInput extends AcceptedRevisionKey { + readonly parsedDeclaration: unknown; +} + +export type AcceptedRevisionResult = + | { readonly accepted: true; readonly acceptedRevisionReference: string; readonly existing: boolean } + | { readonly accepted: false; readonly reason: "equivocation" }; + +export interface AcceptedRevisionLookupResult extends AcceptedRevisionKey { + readonly acceptedRevisionReference: string; + readonly parsedDeclaration: unknown; +} + +export interface AcceptedSourceDeclarationRevisionStore { + accept: (input: AcceptedRevisionInput) => Promise<AcceptedRevisionResult>; + getByReference: (acceptedRevisionReference: string) => Promise<AcceptedRevisionLookupResult | null>; +} + +interface StoredRevision { + readonly accepted_revision_reference: string; + readonly authority_binding: string; + readonly canonical_content: string; + readonly content_fingerprint: string; + readonly declaration_version: string; + readonly source_id: string; +} + +interface LegacyStoredRevision { + readonly authority_binding: string; + readonly canonical_content: string; + readonly content_fingerprint: string; + readonly declaration_version: string; + readonly source_id: string; +} + +interface SqliteStatement<Row = unknown> { + all: (...params: never[]) => Row[]; + get: (...params: never[]) => Row | undefined; + run: (...params: never[]) => { readonly changes: number }; +} + +export interface SqliteRevisionDatabase { + exec: (sql: string) => void; + prepare: <Row = unknown>(sql: string) => SqliteStatement<Row>; +} + +export interface PostgresRevisionDatabase { + query: <Row = StoredRevision>( + sql: string, + params?: readonly unknown[] + ) => Promise<{ readonly rowCount: number | null; readonly rows: readonly Row[] }>; +} + +export interface RevisionStoreOptions { + /** Test-only override; production callers use the fixed table name. */ + readonly tableName?: string; +} + +const DEFAULT_TABLE = "accepted_source_declaration_revisions"; +const SQL_IDENTIFIER = /^[a-z_][a-z0-9_]*$/; + +function tableName(options: RevisionStoreOptions): string { + const result = options.tableName ?? DEFAULT_TABLE; + if (!SQL_IDENTIFIER.test(result)) { + throw new TypeError("Revision table name must be a simple lowercase SQL identifier."); + } + return result; +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value === "boolean" || typeof value === "string") { + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError("Parsed declaration JSON must not contain non-finite numbers."); + } + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (typeof value === "object") { + const object = value as Record<string, unknown>; + return `{${Object.keys(object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`) + .join(",")}}`; + } + throw new TypeError("Parsed declaration content must be JSON data."); +} + +function fingerprint(canonicalContent: string): string { + // This is only an implementation accelerator. Equality always compares the + // stored canonical JSON after the fingerprint matches, so a hash collision + // cannot make two parsed declarations equal. + return createHash("sha256").update(canonicalContent).digest("hex"); +} + +export function acceptedRevisionEvidenceReference(input: AcceptedRevisionKey): string { + assertKey(input); + const stableKey = canonicalJson({ + authority_binding: input.authorityBinding, + declaration_version: input.declarationVersion, + source_id: input.sourceId, + }); + return `as-local:accepted-source-declaration-revision:v1:${fingerprint(stableKey)}`; +} + +function assertKey(input: AcceptedRevisionKey): void { + for (const value of [input.authorityBinding, input.sourceId, input.declarationVersion]) { + if (!value) { + throw new TypeError("Accepted revision keys must be non-empty opaque strings."); + } + } +} + +function isSameContent(stored: StoredRevision, canonicalContent: string, contentFingerprint: string): boolean { + return stored.content_fingerprint === contentFingerprint && stored.canonical_content === canonicalContent; +} + +function decodeStoredRevision(stored: StoredRevision): AcceptedRevisionLookupResult { + const recomputedFingerprint = fingerprint(stored.canonical_content); + if (stored.content_fingerprint !== recomputedFingerprint) { + throw new Error("Accepted revision content fingerprint mismatch."); + } + const expectedReference = acceptedRevisionEvidenceReference({ + authorityBinding: stored.authority_binding, + declarationVersion: stored.declaration_version, + sourceId: stored.source_id, + }); + if (stored.accepted_revision_reference !== expectedReference) { + throw new Error("Accepted revision reference does not match stored authority binding."); + } + return { + acceptedRevisionReference: stored.accepted_revision_reference, + authorityBinding: stored.authority_binding, + declarationVersion: stored.declaration_version, + parsedDeclaration: JSON.parse(stored.canonical_content) as unknown, + sourceId: stored.source_id, + }; +} + +function sqliteSchema(table: string): string { + return ` + CREATE TABLE IF NOT EXISTS ${table} ( + authority_binding TEXT NOT NULL, + source_id TEXT NOT NULL, + declaration_version TEXT NOT NULL, + accepted_revision_reference TEXT NOT NULL, + canonical_content TEXT NOT NULL, + content_fingerprint TEXT NOT NULL, + UNIQUE (accepted_revision_reference), + PRIMARY KEY (authority_binding, source_id, declaration_version) + );`; +} + +function postgresSchema(table: string): string { + return ` + CREATE TABLE IF NOT EXISTS ${table} ( + authority_binding TEXT NOT NULL, + source_id TEXT NOT NULL, + declaration_version TEXT NOT NULL, + accepted_revision_reference TEXT NOT NULL, + canonical_content TEXT NOT NULL, + content_fingerprint TEXT NOT NULL, + UNIQUE (accepted_revision_reference), + PRIMARY KEY (authority_binding, source_id, declaration_version) + );`; +} + +function sqliteExistingTableSql(database: SqliteRevisionDatabase, table: string): string | null { + const row = database + .prepare<{ readonly sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(...([table] as never[])); + return row?.sql ?? null; +} + +function createSqliteRevisionTable(database: SqliteRevisionDatabase, table: string): void { + database.exec(sqliteSchema(table)); +} + +function migrateSqliteRevisionTable(database: SqliteRevisionDatabase, table: string): void { + const existingSql = sqliteExistingTableSql(database, table); + if (!existingSql) { + createSqliteRevisionTable(database, table); + return; + } + if ( + existingSql.includes("accepted_revision_reference TEXT NOT NULL") && + existingSql.includes("UNIQUE (accepted_revision_reference)") + ) { + return; + } + + const replacement = `${table}_migration`; + const rows = database + .prepare<LegacyStoredRevision>( + `SELECT authority_binding, source_id, declaration_version, canonical_content, content_fingerprint FROM ${table}` + ) + .all(); + + database.exec("BEGIN IMMEDIATE"); + try { + database.exec(`DROP TABLE IF EXISTS ${replacement}`); + createSqliteRevisionTable(database, replacement); + const insert = database.prepare( + `INSERT INTO ${replacement} (authority_binding, source_id, declaration_version, accepted_revision_reference, canonical_content, content_fingerprint) + VALUES (?, ?, ?, ?, ?, ?)` + ); + for (const row of rows) { + insert.run( + ...([ + row.authority_binding, + row.source_id, + row.declaration_version, + acceptedRevisionEvidenceReference({ + authorityBinding: row.authority_binding, + declarationVersion: row.declaration_version, + sourceId: row.source_id, + }), + row.canonical_content, + row.content_fingerprint, + ] as never[]) + ); + } + database.exec(`DROP TABLE ${table}`); + database.exec(`ALTER TABLE ${replacement} RENAME TO ${table}`); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } +} + +export function createSqliteAcceptedSourceDeclarationRevisionStore( + database: SqliteRevisionDatabase, + options: RevisionStoreOptions = {} +): AcceptedSourceDeclarationRevisionStore { + const table = tableName(options); + migrateSqliteRevisionTable(database, table); + const insert = database.prepare( + `INSERT INTO ${table} (authority_binding, source_id, declaration_version, accepted_revision_reference, canonical_content, content_fingerprint) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(authority_binding, source_id, declaration_version) DO NOTHING` + ); + const read = database.prepare<StoredRevision>( + `SELECT authority_binding, source_id, declaration_version, accepted_revision_reference, canonical_content, content_fingerprint FROM ${table} + WHERE authority_binding = ? AND source_id = ? AND declaration_version = ?` + ); + const readByReference = database.prepare<StoredRevision>( + `SELECT authority_binding, source_id, declaration_version, accepted_revision_reference, canonical_content, content_fingerprint FROM ${table} + WHERE accepted_revision_reference = ?` + ); + return { + accept(input) { + assertKey(input); + const canonicalContent = canonicalJson(input.parsedDeclaration); + const contentFingerprint = fingerprint(canonicalContent); + const acceptedRevisionReference = acceptedRevisionEvidenceReference(input); + const inserted = insert.run( + ...([ + input.authorityBinding, + input.sourceId, + input.declarationVersion, + acceptedRevisionReference, + canonicalContent, + contentFingerprint, + ] as never[]) + ); + if (inserted.changes === 1) { + return Promise.resolve({ accepted: true, acceptedRevisionReference, existing: false } as const); + } + const stored = read.get(...([input.authorityBinding, input.sourceId, input.declarationVersion] as never[])); + if (!stored) { + throw new Error("Accepted revision disappeared after a conflicting insert."); + } + return Promise.resolve( + isSameContent(stored, canonicalContent, contentFingerprint) + ? { accepted: true, acceptedRevisionReference: stored.accepted_revision_reference, existing: true } + : { accepted: false, reason: "equivocation" } + ); + }, + getByReference(acceptedRevisionReference) { + const stored = readByReference.get(...([acceptedRevisionReference] as never[])); + return Promise.resolve().then(() => (stored ? decodeStoredRevision(stored) : null)); + }, + }; +} + +export async function createPostgresAcceptedSourceDeclarationRevisionStore( + database: PostgresRevisionDatabase, + options: RevisionStoreOptions = {} +): Promise<AcceptedSourceDeclarationRevisionStore> { + const table = tableName(options); + await database.query(postgresSchema(table)); + await database.query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS accepted_revision_reference TEXT`); + const missingReferences = await database.query<{ + readonly authority_binding: string; + readonly declaration_version: string; + readonly source_id: string; + }>( + `SELECT authority_binding, source_id, declaration_version FROM ${table} + WHERE accepted_revision_reference IS NULL` + ); + await Promise.all( + missingReferences.rows.map((row) => + database.query( + `UPDATE ${table} + SET accepted_revision_reference = $1 + WHERE authority_binding = $2 AND source_id = $3 AND declaration_version = $4`, + [ + acceptedRevisionEvidenceReference({ + authorityBinding: row.authority_binding, + declarationVersion: row.declaration_version, + sourceId: row.source_id, + }), + row.authority_binding, + row.source_id, + row.declaration_version, + ] + ) + ) + ); + await database.query(`ALTER TABLE ${table} ALTER COLUMN accepted_revision_reference SET NOT NULL`); + await database.query( + `CREATE UNIQUE INDEX IF NOT EXISTS ${table}_accepted_revision_reference_key + ON ${table} (accepted_revision_reference)` + ); + return { + async accept(input) { + assertKey(input); + const canonicalContent = canonicalJson(input.parsedDeclaration); + const contentFingerprint = fingerprint(canonicalContent); + const acceptedRevisionReference = acceptedRevisionEvidenceReference(input); + const inserted = await database.query( + `INSERT INTO ${table} (authority_binding, source_id, declaration_version, accepted_revision_reference, canonical_content, content_fingerprint) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT(authority_binding, source_id, declaration_version) DO NOTHING + RETURNING accepted_revision_reference, canonical_content, content_fingerprint`, + [ + input.authorityBinding, + input.sourceId, + input.declarationVersion, + acceptedRevisionReference, + canonicalContent, + contentFingerprint, + ] + ); + if (inserted.rowCount === 1) { + return { accepted: true, acceptedRevisionReference, existing: false }; + } + const existing = await database.query( + `SELECT authority_binding, source_id, declaration_version, accepted_revision_reference, canonical_content, content_fingerprint FROM ${table} + WHERE authority_binding = $1 AND source_id = $2 AND declaration_version = $3`, + [input.authorityBinding, input.sourceId, input.declarationVersion] + ); + const stored = existing.rows.at(0); + if (!stored) { + throw new Error("Accepted revision disappeared after a conflicting insert."); + } + return isSameContent(stored, canonicalContent, contentFingerprint) + ? { accepted: true, acceptedRevisionReference: stored.accepted_revision_reference, existing: true } + : { accepted: false, reason: "equivocation" }; + }, + async getByReference(acceptedRevisionReference) { + const existing = await database.query( + `SELECT authority_binding, source_id, declaration_version, accepted_revision_reference, canonical_content, content_fingerprint FROM ${table} + WHERE accepted_revision_reference = $1`, + [acceptedRevisionReference] + ); + const stored = existing.rows.at(0); + return stored ? decodeStoredRevision(stored) : null; + }, + }; +} diff --git a/reference-implementation/server/source-declaration-trust/service.ts b/reference-implementation/server/source-declaration-trust/service.ts new file mode 100644 index 000000000..2fc648e58 --- /dev/null +++ b/reference-implementation/server/source-declaration-trust/service.ts @@ -0,0 +1,78 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Small composition boundary for standalone provider-native declaration trust. + * It has no grant, consent, catalog, or onboarding dependency: callers supply + * an authority binding already accepted by their onboarding path. + */ + +import { + type DeclarationRetrievalDependencies, + type DeclarationRetrievalPolicy, + retrieveSourceDeclaration, +} from "./retrieval.ts"; +import type { AcceptedSourceDeclarationRevisionStore } from "./revision-store.ts"; + +export interface AcceptedProviderNativeDeclarationRevision { + readonly acceptedRevisionReference: string; + readonly authorityBinding: string; + readonly declarationVersion: string; + readonly parsedDeclaration: unknown; + readonly sourceId: string; +} + +export type AcceptProviderNativeDeclarationResult = + | { + readonly ok: true; + readonly acceptedRevisionReference: string; + readonly declarationVersion: string; + readonly finalUrl: string; + } + | { readonly ok: false; readonly reason: string }; + +export async function retrieveAndAcceptProviderNativeDeclaration( + input: { + readonly acceptedPointer: string; + readonly authorityBinding: string; + readonly expectedSourceId: string; + }, + dependencies: DeclarationRetrievalDependencies & { + readonly revisionStore: AcceptedSourceDeclarationRevisionStore; + }, + policy: DeclarationRetrievalPolicy +): Promise<AcceptProviderNativeDeclarationResult> { + const retrieved = await retrieveSourceDeclaration( + { acceptedPointer: input.acceptedPointer, expectedSourceId: input.expectedSourceId }, + policy, + dependencies + ); + if (!retrieved.ok) { + return retrieved; + } + if (retrieved.value.declaration.source.kind !== "provider_native") { + return { ok: false, reason: "source_kind_mismatch" }; + } + const persisted = await dependencies.revisionStore.accept({ + authorityBinding: input.authorityBinding, + declarationVersion: retrieved.value.declaration.declaration_version, + parsedDeclaration: retrieved.value.declaration, + sourceId: input.expectedSourceId, + }); + if (!persisted.accepted) { + return { ok: false, reason: persisted.reason }; + } + return { + acceptedRevisionReference: persisted.acceptedRevisionReference, + declarationVersion: retrieved.value.declaration.declaration_version, + finalUrl: retrieved.value.finalUrl, + ok: true, + }; +} + +export function getAcceptedProviderNativeDeclarationRevision( + input: { readonly acceptedRevisionReference: string }, + dependencies: { readonly revisionStore: AcceptedSourceDeclarationRevisionStore } +): Promise<AcceptedProviderNativeDeclarationRevision | null> { + return dependencies.revisionStore.getByReference(input.acceptedRevisionReference); +} diff --git a/reference-implementation/server/source-declaration.ts b/reference-implementation/server/source-declaration.ts new file mode 100644 index 000000000..fd4301959 --- /dev/null +++ b/reference-implementation/server/source-declaration.ts @@ -0,0 +1,113 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { createRequire } from "node:module"; +import { + type SourceDeclaration, + SourceDeclarationSchema, + validateSourceDeclarationSemantics, +} from "@pdpp/reference-contract/public/source"; + +interface SchemaError { + instancePath?: string; + message?: string; +} + +interface SchemaValidator { + errors?: SchemaError[] | null; + (value: unknown): boolean; +} + +interface AjvInstance { + compile: (schema: object) => SchemaValidator; + errors?: SchemaError[] | null; + validateSchema: (schema: object) => boolean; +} + +type JsonObject = Record<string, unknown>; + +const requireFromContract = createRequire(import.meta.resolve("@pdpp/reference-contract")); +const Ajv2020 = requireFromContract("ajv/dist/2020.js") as new (options?: JsonObject) => AjvInstance; +const addFormats = requireFromContract("ajv-formats") as (ajv: AjvInstance) => void; +const ajv = new Ajv2020({ allErrors: true, strict: false }); +addFormats(ajv); +const validateSourceDeclarationSchema = ajv.compile(SourceDeclarationSchema); + +export class InvalidSourceDeclarationError extends Error { + readonly code = "source.declaration_invalid"; +} + +function cloneJson<T>(value: T): T { + return structuredClone(value); +} + +function deepFreezeJson<T>(value: T): T { + if (value !== null && typeof value === "object") { + for (const child of Object.values(value)) { + deepFreezeJson(child); + } + Object.freeze(value); + } + return value; +} + +function validationMessage(): string { + const structural = (validateSourceDeclarationSchema.errors ?? []).map( + (error) => `${error.instancePath || "/"} ${error.message || "is invalid"}` + ); + return structural.join("; "); +} + +function assertLocalSchemaReferences(value: unknown, path: string): void { + if (Array.isArray(value)) { + for (const [index, child] of value.entries()) { + assertLocalSchemaReferences(child, `${path}/${index}`); + } + return; + } + if (!(value && typeof value === "object")) { + return; + } + for (const [key, child] of Object.entries(value)) { + const childPath = `${path}/${key}`; + if ((key === "$ref" || key === "$dynamicRef") && (typeof child !== "string" || !child.startsWith("#"))) { + throw new InvalidSourceDeclarationError(`${childPath} must be a local fragment reference`); + } + assertLocalSchemaReferences(child, childPath); + } +} + +function requireEmbeddedStreamSchemas(declaration: SourceDeclaration): void { + for (const [index, stream] of declaration.streams.entries()) { + if (!ajv.validateSchema(stream.schema)) { + const details = (ajv.errors ?? []) + .map((error) => `${error.instancePath || "/"} ${error.message || "is invalid"}`) + .join("; "); + throw new InvalidSourceDeclarationError( + `Invalid SourceDeclaration stream schema at /streams/${index}/schema: ${details}` + ); + } + assertLocalSchemaReferences(stream.schema, `/streams/${index}/schema`); + } +} + +/** Parse an untrusted value through the common connector/native Core boundary. */ +export function requireSourceDeclaration(value: unknown): SourceDeclaration { + const candidate = cloneJson(value); + if (!validateSourceDeclarationSchema(candidate)) { + throw new InvalidSourceDeclarationError(`Invalid SourceDeclaration: ${validationMessage()}`); + } + const declaration = candidate as SourceDeclaration; + requireEmbeddedStreamSchemas(declaration); + const semantic = validateSourceDeclarationSemantics(declaration); + if (!semantic.ok) { + const details = semantic.failures.map((failure) => `${failure.path}: ${failure.code}`).join("; "); + throw new InvalidSourceDeclarationError(`Invalid SourceDeclaration semantics: ${details}`); + } + return declaration; +} + +/** Validate and retain one detached immutable SourceDeclaration value. */ +export function snapshotSourceDeclaration(value: unknown): SourceDeclaration { + return deepFreezeJson(requireSourceDeclaration(value)); +} diff --git a/reference-implementation/server/source-descriptor.ts b/reference-implementation/server/source-descriptor.ts index f2c2dd0fa..b0451e00d 100644 --- a/reference-implementation/server/source-descriptor.ts +++ b/reference-implementation/server/source-descriptor.ts @@ -18,7 +18,9 @@ export interface StorageBinding { } export interface NativeManifest { + /** Retained only as an ignored implementation-specific manifest field. */ provider_id?: string; + source_declaration?: { source?: unknown }; storage_binding?: StorageBinding; } @@ -62,16 +64,7 @@ export function resolveGrantStorageBinding(tokenInfo: TokenInfo | null | undefin } export function buildClientSourceDescriptor(tokenInfo: TokenInfo | null | undefined): SourceDescriptor | null { - const grantSource = buildSourceDescriptor(tokenInfo?.grant?.source); - if (grantSource) { - return grantSource; - } - - const storageBinding = resolveGrantStorageBinding(tokenInfo); - if (storageBinding?.connector_id) { - return { id: storageBinding.connector_id, kind: "connector" }; - } - return null; + return buildSourceDescriptor(tokenInfo?.grant?.source); } export function buildOwnerQuerySourceDescriptor( @@ -79,8 +72,9 @@ export function buildOwnerQuerySourceDescriptor( opts: SourceDescriptorOptions = {} ): SourceDescriptor | null { const nativeManifest = resolveNativeManifest(opts); - if (nativeManifest?.provider_id) { - return buildSourceDescriptor({ id: nativeManifest.provider_id, kind: "provider_native" }); + const configuredSource = buildSourceDescriptor(nativeManifest?.source_declaration?.source); + if (configuredSource) { + return configuredSource; } const connectorId = resolveSingleConnectorIdQueryValue(req.query.connector_id); @@ -108,10 +102,17 @@ export async function resolveOwnerReadScope(req: RequestWithQuery, opts: SourceD const nativeManifest = resolveNativeManifest(opts); const nativeStorageBinding = resolveNativeStorageBinding(opts); if (nativeManifest && nativeStorageBinding) { + const configuredSource = buildSourceDescriptor(nativeManifest.source_declaration?.source); + if (!configuredSource) { + const err = Object.assign(new Error("Configured SourceDeclaration source is missing"), { + code: "invalid_request", + }); + throw err; + } return { owner_subject_id: getOwnerTokenSubjectId(req), public_scope: "native", - source: { id: nativeManifest.provider_id, kind: "provider_native" }, + source: configuredSource, storage_binding: nativeStorageBinding, }; } diff --git a/reference-implementation/server/source-introspection-context.ts b/reference-implementation/server/source-introspection-context.ts new file mode 100644 index 000000000..a1c7da4d0 --- /dev/null +++ b/reference-implementation/server/source-introspection-context.ts @@ -0,0 +1,189 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ResolvedGrantStream } from "@pdpp/reference-contract/public/source"; +import { parseCoreResolvedGrant } from "./core-source-authorization.ts"; +import { + buildGrantedAuthorizationDetail, + requireGrantedAuthorizationDetailEnvelope, +} from "./source-approved-authorization.ts"; + +type JsonObject = Record<string, unknown>; + +export type SourceIntrospectionFailureCode = + | "context.field_not_granted" + | "context.grant_mismatch" + | "context.identity_mismatch" + | "context.instance_mismatch" + | "context.kind_mismatch" + | "context.rights_duplicated" + | "context.rights_missing" + | "context.source_mismatch" + | "context.stream_not_allowed"; + +export class SourceIntrospectionContextError extends Error { + readonly code: SourceIntrospectionFailureCode; + + constructor(code: SourceIntrospectionFailureCode, message: string) { + super(message); + this.code = code; + } +} + +export interface SourceReadRequest { + readonly fields?: readonly string[] | undefined; + readonly instance_id?: string | undefined; + readonly stream: string; +} + +const SUPPLEMENTARY_RIGHT_KEYS = [ + "authorization_details", + "fields", + "instance_ids", + "resources", + "streams", + "time_constraint", +] as const; + +function fail(code: SourceIntrospectionFailureCode, message: string): never { + throw new SourceIntrospectionContextError(code, message); +} + +function requireObject(value: unknown, code: SourceIntrospectionFailureCode, label: string): JsonObject { + if (!(value && typeof value === "object" && !Array.isArray(value))) { + fail(code, `${label} is missing or invalid`); + } + return value as JsonObject; +} + +function requireSingleDetail(info: JsonObject): JsonObject { + if (!Array.isArray(info.authorization_details) || info.authorization_details.length !== 1) { + fail("context.rights_missing", "Introspection must carry one granted authorization detail"); + } + return requireObject(info.authorization_details[0], "context.rights_missing", "Granted authorization detail"); +} + +function rejectDuplicatedRights(info: JsonObject, pdpp: JsonObject): void { + if (info.grant !== undefined) { + fail("context.rights_duplicated", "Supplementary context duplicates approved rights"); + } + const duplicatedRight = SUPPLEMENTARY_RIGHT_KEYS.find((key) => pdpp[key] !== undefined); + if (duplicatedRight) { + fail("context.rights_duplicated", `PDPP context duplicates '${duplicatedRight}' rights`); + } +} + +function requireBindingIdentity(info: JsonObject, pdpp: JsonObject): void { + if (info.client_id !== pdpp.client_id || info.subject_id !== pdpp.subject_id) { + fail("context.identity_mismatch", "Introspection identity does not match its PDPP context"); + } + if (info.grant_id !== pdpp.grant_id) { + fail("context.grant_mismatch", "Introspection grant does not match its PDPP context"); + } +} + +function requireBindingContext(info: JsonObject): JsonObject { + const pdpp = requireObject(info.pdpp, "context.kind_mismatch", "PDPP context"); + rejectDuplicatedRights(info, pdpp); + if (pdpp.context_kind !== "oauth_rar_0_1") { + fail("context.kind_mismatch", "PDPP context kind is not supported"); + } + requireBindingIdentity(info, pdpp); + return pdpp; +} + +function requireMatchingSource(detail: JsonObject, pdpp: JsonObject): JsonObject { + const source = requireObject(detail.source, "context.source_mismatch", "Granted source"); + const contextSource = requireObject(pdpp.source, "context.source_mismatch", "PDPP source"); + if (source.id !== contextSource.id || source.kind !== contextSource.kind) { + fail("context.source_mismatch", "Granted source does not match its PDPP context"); + } + return source; +} + +function resolvedGrantInput(info: JsonObject, pdpp: JsonObject, detail: JsonObject, source: JsonObject): JsonObject { + return { + access_mode: detail.access_mode, + client: { client_id: pdpp.client_id }, + expires_at: typeof info.exp === "number" ? new Date(info.exp * 1000).toISOString() : null, + grant_id: pdpp.grant_id, + issued_at: pdpp.issued_at, + ...(detail.purpose_description ? { purpose_description: detail.purpose_description } : {}), + purpose_code: detail.purpose_code, + ...(detail.retention ? { retention: detail.retention } : {}), + ...(detail.selection_preset ? { selection_preset: detail.selection_preset } : {}), + source, + source_declaration: pdpp.source_declaration, + streams: detail.streams, + subject: { id: pdpp.subject_id }, + version: "0.1.0", + }; +} + +function parseGrantedContext(info: JsonObject, pdpp: JsonObject, detail: JsonObject, source: JsonObject) { + try { + requireGrantedAuthorizationDetailEnvelope(detail); + return parseCoreResolvedGrant(resolvedGrantInput(info, pdpp, detail, source)); + } catch (cause: unknown) { + const error = new SourceIntrospectionContextError( + "context.rights_missing", + "Granted authorization detail is invalid" + ); + error.cause = cause; + throw error; + } +} + +/** Resolve complete client context from one authenticated introspection response. */ +export function resolveSourceIntrospectionContext(value: unknown): JsonObject { + const info = requireObject(value, "context.rights_missing", "Introspection response"); + const pdpp = requireBindingContext(info); + const detail = requireSingleDetail(info); + const source = requireMatchingSource(detail, pdpp); + const grant = parseGrantedContext(info, pdpp, detail, source); + return { ...info, grant }; +} + +function requireApprovedStream(value: unknown, streamName: string): ResolvedGrantStream { + const info = requireObject(value, "context.rights_missing", "Resolved authorization context"); + const stream = parseCoreResolvedGrant(info.grant).streams.find((candidate) => candidate.name === streamName); + return stream ?? fail("context.stream_not_allowed", `Stream '${streamName}' is not approved`); +} + +function enforceSelectors(stream: ResolvedGrantStream, request: SourceReadRequest): void { + if (request.instance_id && !stream.instance_ids.includes(request.instance_id)) { + fail("context.instance_mismatch", "The requested source instance is not approved"); + } + if (request.fields?.some((field) => !stream.fields.includes(field))) { + fail("context.field_not_granted", "The requested field is not approved"); + } +} + +/** Enforce request selectors from the response-derived grant before route handling. */ +export function enforceSourceReadRequest(value: unknown, request: SourceReadRequest): void { + const stream = requireApprovedStream(value, request.stream); + enforceSelectors(stream, request); +} + +/** Project the AS-internal grant into the single-rights RFC 7662 wire carrier. */ +export function projectSourceIntrospectionWireContext(value: unknown): JsonObject { + const info = requireObject(value, "context.rights_missing", "Introspection response"); + if (info.pdpp_token_kind !== "client") { + return { ...info }; + } + const grant = parseCoreResolvedGrant(info.grant); + const { grant: _internalGrant, ...bindingAndLifecycle } = info; + return { + ...bindingAndLifecycle, + authorization_details: [buildGrantedAuthorizationDetail(grant)], + pdpp: { + client_id: info.client_id, + context_kind: "oauth_rar_0_1", + grant_id: info.grant_id, + issued_at: grant.issued_at, + source: grant.source, + source_declaration: grant.source_declaration, + subject_id: info.subject_id, + }, + }; +} diff --git a/reference-implementation/test/accepted-provider-native-consent.test.ts b/reference-implementation/test/accepted-provider-native-consent.test.ts new file mode 100644 index 000000000..3771ba1e0 --- /dev/null +++ b/reference-implementation/test/accepted-provider-native-consent.test.ts @@ -0,0 +1,397 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { validateResponse } from "@pdpp/reference-contract"; +// biome-ignore lint/correctness/noUnresolvedImports: Node and TypeScript resolve this declared runtime dependency. +import Database from "better-sqlite3"; + +import { + initiateGrant, + parsePendingConsentRequestUri, + registerConnector, + seedPreRegisteredClients, +} from "../server/auth.ts"; +import { getDb } from "../server/db.ts"; +import { startServer } from "../server/index.ts"; +import { closePostgresStorage, postgresQuery } from "../server/postgres-storage.ts"; +import { createSqliteAcceptedSourceDeclarationRevisionStore } from "../server/source-declaration-trust/revision-store.ts"; +import { retrieveAndAcceptProviderNativeDeclaration } from "../server/source-declaration-trust/service.ts"; +import { dedicatedPostgresTestUrl } from "./helpers/dedicated-postgres-test-url.ts"; +import { withTemporaryPostgresDatabase } from "./helpers/postgres-temp-database.ts"; + +const CLIENT_ID = "accepted_revision_consent_client"; +const POINTER = "https://declarations.example.test/northstar/current.json"; +const AUTHORITY = "metadata:https://northstar.example/pdpp"; +const NOT_FOUND_RE = /not found/; +const PUBLISHER_ATTRIBUTION_RE = /Publisher attribution/; +const RESOURCE_AUTHORITY_RE = /Resource authority/; +const UNVERIFIED_RE = /unverified/; + +interface TestServerHandle { + asPort: number; + asServer: { close: (callback: () => void) => void; closeAllConnections?: () => void }; + rsServer: { close: (callback: () => void) => void; closeAllConnections?: () => void }; +} + +interface ValidatedTestDeclaration extends Record<string, unknown> { + declaration_version: string; + source: { id: string; kind: string }; +} + +function streamBody(value: string): ReadableStream<Uint8Array> { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(value)); + controller.close(); + }, + }); +} + +async function closeServer(server: TestServerHandle): Promise<void> { + server.asServer.closeAllConnections?.(); + server.rsServer.closeAllConnections?.(); + const close = (value: TestServerHandle["asServer"]) => new Promise<void>((resolve) => value.close(resolve)); + await Promise.allSettled([close(server.asServer), close(server.rsServer)]); +} + +async function jsonPost(url: string, body: unknown): Promise<{ body: Record<string, unknown>; status: number }> { + const response = await fetch(url, { + body: JSON.stringify(body), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + return { body: (await response.json()) as Record<string, unknown>, status: response.status }; +} + +test("HTTP consent consumes one accepted provider-native revision without discovery refetch", async () => { + const revisionDatabase = new Database(":memory:"); + const revisionStore = createSqliteAcceptedSourceDeclarationRevisionStore(revisionDatabase); + const nativeManifest = JSON.parse( + readFileSync(new URL("../manifests/northstar-hr.json", import.meta.url), "utf8") + ) as Record<string, unknown>; + const declarationA = structuredClone(nativeManifest.source_declaration) as ValidatedTestDeclaration; + declarationA.declaration_version = "accepted:northstar:a"; + const declarationB = structuredClone(declarationA); + declarationB.declaration_version = "accepted:northstar:b"; + declarationB.display = { name: "Northstar HR revision B" }; + const sourceId = (declarationA.source as Record<string, unknown>).id as string; + let liveDeclaration = declarationA; + let retrievalOnline = true; + const retrievalDependencies = { + fetch: () => { + if (!retrievalOnline) { + throw new Error("discovery is offline"); + } + return Promise.resolve({ body: streamBody(JSON.stringify(liveDeclaration)), status: 200 }); + }, + resolveDns: () => Promise.resolve(["203.0.113.4"]), + revisionStore, + validateAddress: () => Promise.resolve(true), + validateDeclaration: (value: unknown) => ({ declaration: value as typeof declarationA, ok: true as const }), + }; + const acceptedA = await retrieveAndAcceptProviderNativeDeclaration( + { acceptedPointer: POINTER, authorityBinding: AUTHORITY, expectedSourceId: sourceId }, + retrievalDependencies, + { maxAddresses: 4, maxBytes: 65_536, maxRedirects: 1, timeoutMs: 1000 } + ); + assert.equal(acceptedA.ok, true); + if (!acceptedA.ok) { + assert.fail("revision A was not accepted"); + } + + const fulfillmentManifest = { ...nativeManifest, source_declaration: declarationB }; + const server = (await startServer({ + acceptedProviderNativeRevision: { + acceptedRevisionReference: acceptedA.acceptedRevisionReference, + revisionStore, + sourceId, + }, + asPort: 0, + dbPath: ":memory:", + nativeManifest: fulfillmentManifest, + quiet: true, + rsPort: 0, + })) as TestServerHandle; + const asUrl = `http://localhost:${server.asPort}`; + try { + await seedPreRegisteredClients([ + { client_id: CLIENT_ID, client_name: "Accepted revision consent", registration_mode: "pre_registered_public" }, + ]); + const par = await jsonPost(`${asUrl}/oauth/par`, { + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/financial_planning", + source: { id: sourceId, kind: "provider_native" }, + streams: [{ name: "pay_statements" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }); + assert.equal(par.status, 201, JSON.stringify(par.body)); + assert.equal(typeof par.body.request_uri, "string"); + + liveDeclaration = declarationB; + const acceptedB = await retrieveAndAcceptProviderNativeDeclaration( + { acceptedPointer: POINTER, authorityBinding: AUTHORITY, expectedSourceId: sourceId }, + retrievalDependencies, + { maxAddresses: 4, maxBytes: 65_536, maxRedirects: 1, timeoutMs: 1000 } + ); + assert.equal(acceptedB.ok, true); + retrievalOnline = false; + + const review = await jsonPost(`${asUrl}/consent/review`, { + request_uri: par.body.request_uri, + subject_id: "owner_local", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + const artifact = review.body.approval_review as Record<string, unknown>; + const declarationEvidence = artifact.source_declaration as Record<string, unknown>; + assert.equal(declarationEvidence.version, "accepted:northstar:a"); + assert.equal(declarationEvidence.accepted_revision_reference, acceptedA.acceptedRevisionReference); + assert.deepEqual(declarationEvidence.resource_authority, { authority_binding: AUTHORITY, status: "verified" }); + assert.deepEqual(declarationEvidence.publisher_attribution, { + id: (declarationA.publisher as Record<string, unknown>).id, + status: "unverified", + }); + for (const malformed of [ + { ...declarationEvidence, accepted_revision_reference: undefined }, + { + ...declarationEvidence, + resource_authority: { status: "local_operator_provisioned" }, + }, + ]) { + const malformedArtifact = { ...artifact, source_declaration: malformed }; + const validation = validateResponse("reviewConsent", { + body: { ...review.body, approval_review: malformedArtifact }, + status: 200, + }); + assert.equal(validation.ok, false, "partial or mixed provider-native evidence must fail the public contract"); + } + const resumed = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(String(par.body.request_uri))}`); + const resumedHtml = await resumed.text(); + assert.equal(resumed.status, 200, resumedHtml); + assert.match(resumedHtml, RESOURCE_AUTHORITY_RE); + assert.match(resumedHtml, new RegExp(`Verified \\(${AUTHORITY.replaceAll(".", "\\.")}\\)`)); + assert.match(resumedHtml, PUBLISHER_ATTRIBUTION_RE); + assert.match(resumedHtml, UNVERIFIED_RE); + assert.match(resumedHtml, new RegExp(acceptedA.acceptedRevisionReference.replaceAll(".", "\\."))); + + const approved = await jsonPost(`${asUrl}/consent/approve`, { + approval_review_revision: review.body.approval_review_revision, + request_uri: par.body.request_uri, + }); + assert.equal(approved.status, 200, JSON.stringify(approved.body)); + const grant = approved.body.grant as Record<string, unknown>; + assert.deepEqual(grant.source_declaration, { version: "accepted:northstar:a" }); + assert.equal(JSON.stringify(grant).includes("accepted_revision_reference"), false); + + const grantId = grant.grant_id as string; + const events = getDb() + .prepare( + "SELECT data_json FROM spine_events WHERE grant_id = ? AND event_type IN ('consent.approved', 'grant.issued')" + ) + .all(grantId) as Array<{ data_json: string }>; + assert.equal(events.length, 2); + for (const event of events) { + const evidence = (JSON.parse(event.data_json).source_declaration_snapshot ?? {}) as Record<string, unknown>; + assert.equal(evidence.declaration_version, "accepted:northstar:a"); + assert.equal(evidence.accepted_revision_reference, acceptedA.acceptedRevisionReference); + assert.deepEqual(evidence.resource_authority, { authority_binding: AUTHORITY, status: "verified" }); + } + + const spotify = JSON.parse(readFileSync(new URL("../manifests/spotify.json", import.meta.url), "utf8")) as Record< + string, + unknown + >; + await registerConnector(spotify); + const unrelated = await jsonPost(`${asUrl}/oauth/par`, { + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: "https://registry.pdpp.dev/connectors/spotify", kind: "connector" }, + streams: [{ name: "top_artists" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }); + assert.equal(unrelated.status, 201, JSON.stringify(unrelated.body)); + + const tampered = await jsonPost(`${asUrl}/oauth/par`, { + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/financial_planning", + source: { id: sourceId, kind: "provider_native" }, + streams: [{ name: "pay_statements" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }); + assert.equal(tampered.status, 201, JSON.stringify(tampered.body)); + const deviceCode = parsePendingConsentRequestUri(tampered.body.request_uri); + assert.ok(deviceCode); + const row = getDb().prepare("SELECT params_json FROM pending_consents WHERE device_code = ?").get(deviceCode) as { + params_json: string; + }; + const params = JSON.parse(row.params_json) as Record<string, unknown>; + const snapshot = params.source_declaration_snapshot as Record<string, unknown>; + snapshot.accepted_revision_reference = `${acceptedA.acceptedRevisionReference}:tampered`; + getDb() + .prepare("UPDATE pending_consents SET params_json = ? WHERE device_code = ?") + .run(JSON.stringify(params), deviceCode); + const rejectedReview = await jsonPost(`${asUrl}/consent/review`, { + request_uri: tampered.body.request_uri, + subject_id: "owner_local", + }); + assert.equal(rejectedReview.status, 400); + assert.equal((rejectedReview.body.error as Record<string, unknown>).code, "invalid_request"); + + await assert.rejects( + initiateGrant( + { + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/financial_planning", + source: { id: sourceId, kind: "provider_native" }, + streams: [{ name: "pay_statements" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }, + { + acceptedRevisionReference: "urn:pdpp:accepted-source-declaration:missing", + acceptedRevisionStore: revisionStore, + nativeManifest: fulfillmentManifest, + nativeManifestMode: "fulfillment_only", + } + ), + NOT_FOUND_RE + ); + } finally { + await closeServer(server); + revisionDatabase.close(); + } +}); + +const POSTGRES_URL = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); + +if (POSTGRES_URL) { + test("PostgreSQL HTTP consent persists accepted revision review and audit evidence", async () => { + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: POSTGRES_URL, + databaseName: `pdpp_test_accepted_bridge_${process.pid.toString(16).padStart(8, "0").slice(-8)}_1`, + }, + async (databaseUrl) => { + const revisionDatabase = new Database(":memory:"); + const revisionStore = createSqliteAcceptedSourceDeclarationRevisionStore(revisionDatabase); + const nativeManifest = JSON.parse( + readFileSync(new URL("../manifests/northstar-hr.json", import.meta.url), "utf8") + ) as Record<string, unknown>; + const declaration = structuredClone(nativeManifest.source_declaration) as ValidatedTestDeclaration; + declaration.declaration_version = "accepted:northstar:postgres"; + const sourceId = (declaration.source as Record<string, unknown>).id as string; + const accepted = await retrieveAndAcceptProviderNativeDeclaration( + { acceptedPointer: POINTER, authorityBinding: AUTHORITY, expectedSourceId: sourceId }, + { + fetch: () => Promise.resolve({ body: streamBody(JSON.stringify(declaration)), status: 200 }), + resolveDns: () => Promise.resolve(["203.0.113.4"]), + revisionStore, + validateAddress: () => Promise.resolve(true), + validateDeclaration: (value: unknown) => ({ declaration: value as typeof declaration, ok: true as const }), + }, + { maxAddresses: 4, maxBytes: 65_536, maxRedirects: 1, timeoutMs: 1000 } + ); + assert.equal(accepted.ok, true); + if (!accepted.ok) { + assert.fail("PostgreSQL fixture revision was not accepted"); + } + let server: TestServerHandle | null = null; + try { + server = (await startServer({ + acceptedProviderNativeRevision: { + acceptedRevisionReference: accepted.acceptedRevisionReference, + revisionStore, + sourceId, + }, + asPort: 0, + databaseUrl, + dbPath: ":memory:", + nativeManifest: { ...nativeManifest, source_declaration: declaration }, + quiet: true, + rsPort: 0, + startClientEventDeliveryWorker: false, + storageBackend: "postgres", + })) as TestServerHandle; + await seedPreRegisteredClients([ + { + client_id: CLIENT_ID, + client_name: "Accepted revision consent", + registration_mode: "pre_registered_public", + }, + ]); + const asUrl = `http://localhost:${server.asPort}`; + const par = await jsonPost(`${asUrl}/oauth/par`, { + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/financial_planning", + source: { id: sourceId, kind: "provider_native" }, + streams: [{ name: "pay_statements" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }); + assert.equal(par.status, 201, JSON.stringify(par.body)); + const review = await jsonPost(`${asUrl}/consent/review`, { + request_uri: par.body.request_uri, + subject_id: "owner_local", + }); + if (review.status !== 200) { + const pending = await postgresQuery<{ params_json: Record<string, unknown> }>( + "SELECT params_json FROM pending_consents ORDER BY created_at DESC LIMIT 1" + ); + assert.equal(review.status, 200, JSON.stringify({ error: review.body, pending: pending.rows[0] })); + } + const sourceDeclaration = (review.body.approval_review as Record<string, unknown>) + .source_declaration as Record<string, unknown>; + assert.equal(sourceDeclaration.accepted_revision_reference, accepted.acceptedRevisionReference); + const approved = await jsonPost(`${asUrl}/consent/approve`, { + approval_review_revision: review.body.approval_review_revision, + request_uri: par.body.request_uri, + }); + assert.equal(approved.status, 200, JSON.stringify(approved.body)); + const grant = approved.body.grant as Record<string, unknown>; + const events = await postgresQuery<{ data_json: Record<string, unknown> }>( + "SELECT data_json FROM spine_events WHERE grant_id = $1 AND event_type IN ('consent.approved', 'grant.issued')", + [grant.grant_id] + ); + assert.equal(events.rows.length, 2); + for (const event of events.rows) { + const evidence = event.data_json.source_declaration_snapshot as Record<string, unknown>; + assert.equal(evidence.accepted_revision_reference, accepted.acceptedRevisionReference); + assert.equal(evidence.declaration_version, "accepted:northstar:postgres"); + } + } finally { + if (server) { + await closeServer(server); + } + await closePostgresStorage(); + revisionDatabase.close(); + } + } + ); + }); +} diff --git a/reference-implementation/test/agent-cli.test.ts b/reference-implementation/test/agent-cli.test.ts index c603e7f69..6ffeefd36 100644 --- a/reference-implementation/test/agent-cli.test.ts +++ b/reference-implementation/test/agent-cli.test.ts @@ -21,8 +21,9 @@ import type { CachedGrant } from "../cli/lib/cache.ts"; const DENIED_POLL_SECRET_PATTERN = /Bearer|owner_local|access_token/; const EXPIRED_POLL_SECRET_PATTERN = /access_token|polling_code/; -const FORBIDDEN_SCOPE_PATTERN = /permission|scope|grant|forbidden/i; +const COMPLETION_SEAM_CRASH_PATTERN = /completion seam crash/; const INVALID_TOKEN_PATTERN = /not-a-real-token/; +const ACCESS_TOKEN_PATTERN = /access_token/; import { deleteGrantFiles, @@ -45,8 +46,24 @@ import { registerClient, stageParRequest, } from "../examples/third-party-app/lib/flow.ts"; +import { exec as dbExec, getOne, referenceQueries } from "../lib/db.ts"; +import { parsePendingConsentRequestUri } from "../server/auth.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; +import { closePostgresStorage, postgresQuery } from "../server/postgres-storage.ts"; import { DEFAULT_LOCAL_DCR_INITIAL_ACCESS_TOKEN } from "../server/reference-local-defaults.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; +import { + __setAgentConnectCleanupAfterMissForTest, + __setAgentConnectCleanupBeforeExpireForTest, + __setAgentConnectCompleteBeforeMarkForTest, + __setAgentConnectCompleteFailureForTest, + __setAgentConnectCreateBeforePersistForTest, +} from "../server/routes/as-agent-connect.ts"; +import { dedicatedPostgresTestUrl } from "./helpers/dedicated-postgres-test-url.ts"; +import { introspectionHeaders } from "./helpers/introspection.ts"; +import { TEST_INTROSPECTION_SERVER_OPTS } from "./helpers/introspection-test-credentials.ts"; +import { withTemporaryPostgresDatabase } from "./helpers/postgres-temp-database.ts"; import { makeTemporaryDir } from "./helpers/temp-dir.ts"; // ─── helpers ────────────────────────────────────────────────────────────────── @@ -95,12 +112,27 @@ async function closeServer(server: TestServer): Promise<void> { async function spinUpServer( opts: Record<string, unknown> = {} ): Promise<{ server: TestServer; asUrl: string; rsUrl: string }> { - const server = (await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0, ...opts })) as TestServer; + const server = (await startServer({ + asPort: 0, + dbPath: ":memory:", + quiet: true, + rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, + ...opts, + })) as TestServer; const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; return { asUrl, rsUrl, server }; } +function rewriteUrlOrigin(url: string, origin: string): string { + const rewritten = new URL(url); + const nextOrigin = new URL(origin); + rewritten.protocol = nextOrigin.protocol; + rewritten.host = nextOrigin.host; + return rewritten.toString(); +} + interface SpotifyManifest { connector_id: string; streams: Array<{ name: string }>; @@ -108,6 +140,7 @@ interface SpotifyManifest { interface AgentConnectStart { approval_url: string; + id: string; polling_code: string; status: string; token_url: string; @@ -131,6 +164,7 @@ async function createAgentConnectRequest({ agentConnectClientId?: string; }): Promise<AgentConnectRequestResult> { const spotifyManifest = await registerSpotify(asUrl); + await seedSpotifyOwnerConnection(spotifyManifest); const registered = await registerClient({ asUrl, initialAccessToken: DEFAULT_LOCAL_DCR_INITIAL_ACCESS_TOKEN, @@ -161,7 +195,8 @@ async function createAgentConnectRequest({ method: "POST", }); const start = (await startResp.json()) as AgentConnectStart; - assert.equal(startResp.status, 201); + assert.equal(startResp.status, 201, JSON.stringify(start)); + assertCredentialNoStoreHeaders(startResp); assert.equal(start.status, "pending"); assert.equal(typeof start.polling_code, "string"); assert.equal(typeof start.approval_url, "string"); @@ -193,6 +228,117 @@ async function pollAgentConnectToken({ return { body, resp }; } +function sqliteCommittedTokenForRequestUri(requestUri: string): string { + const deviceCode = parsePendingConsentRequestUri(requestUri); + assert.ok(deviceCode, "request_uri should parse to device code"); + const row = getOne<{ token_id?: string | null }>(referenceQueries.authPendingConsentsGetByDeviceCode, [deviceCode]); + assert.ok(row?.token_id, "approved pending consent should retain committed token_id"); + return row.token_id; +} + +function sqliteApprovalIdForRequestUri(requestUri: string): string { + const deviceCode = parsePendingConsentRequestUri(requestUri); + assert.ok(deviceCode, "request_uri should parse to device code"); + const row = getOne<{ approval_id?: string | null }>(referenceQueries.authPendingConsentsGetByDeviceCode, [ + deviceCode, + ]); + assert.ok(row?.approval_id, "pending consent should expose approval_id"); + return row.approval_id; +} + +async function postgresApprovalIdForRequestUri(requestUri: string): Promise<string> { + const deviceCode = parsePendingConsentRequestUri(requestUri); + assert.ok(deviceCode, "request_uri should parse to device code"); + const result = await postgresQuery<{ approval_id?: string | null }>( + "SELECT approval_id FROM pending_consents WHERE device_code = $1", + [deviceCode] + ); + const approvalId = result.rows[0]?.approval_id; + assert.ok(approvalId, "pending consent should expose approval_id"); + return approvalId; +} + +async function postgresCommittedTokenForRequestUri(requestUri: string): Promise<string> { + const deviceCode = parsePendingConsentRequestUri(requestUri); + assert.ok(deviceCode, "request_uri should parse to device code"); + const result = await postgresQuery<{ token_id?: string | null }>( + "SELECT token_id FROM pending_consents WHERE device_code = $1", + [deviceCode] + ); + const token = result.rows[0]?.token_id; + assert.ok(token, "approved pending consent should retain committed token_id"); + return token; +} + +async function tokenIsIntrospectionActive(asUrl: string, token: string): Promise<boolean> { + const resp = await fetch(`${asUrl}/introspect`, { + body: JSON.stringify({ token }), + headers: introspectionHeaders(), + method: "POST", + }); + const body = (await resp.json()) as { active?: boolean }; + return body.active === true; +} + +function setSqliteAgentConnectAttemptExpiresAt(id: string, expiresAt: number): void { + dbExec(referenceQueries.authAgentConnectAttemptsSetExpiresAtById, [expiresAt, id]); +} + +function sqliteAgentConnectAttemptCountByStatus(status: string): number { + const row = getOne<{ count?: number }>(referenceQueries.authAgentConnectAttemptsCountByStatus, [status]); + return Number(row?.count ?? 0); +} + +async function setPostgresAgentConnectAttemptExpiresAt(id: string, expiresAt: number): Promise<void> { + await postgresQuery("UPDATE agent_connect_attempts SET expires_at_ms = $1 WHERE id = $2", [expiresAt, id]); +} + +async function postgresAgentConnectAttemptCountByStatus(status: string): Promise<number> { + const result = await postgresQuery<{ count?: string | number }>( + "SELECT COUNT(*) AS count FROM agent_connect_attempts WHERE status = $1", + [status] + ); + return Number(result.rows[0]?.count ?? 0); +} + +function createCleanupPause() { + let releaseCleanup!: () => void; + let observedMiss!: () => void; + const resume = new Promise<void>((resolve) => { + releaseCleanup = resolve; + }); + const paused = new Promise<void>((resolve) => { + observedMiss = resolve; + }); + return { + hook: async () => { + observedMiss(); + await resume; + }, + paused, + release: releaseCleanup, + }; +} + +function createPause() { + let release!: () => void; + let observed!: () => void; + const resume = new Promise<void>((resolve) => { + release = resolve; + }); + const paused = new Promise<void>((resolve) => { + observed = resolve; + }); + return { + hook: async () => { + observed(); + await resume; + }, + paused, + release, + }; +} + function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; } @@ -214,6 +360,11 @@ function errorCode(body: unknown): string | null { return null; } +function assertCredentialNoStoreHeaders(resp: Response): void { + assert.equal(resp.headers.get("cache-control"), "no-store"); + assert.equal(resp.headers.get("pragma"), "no-cache"); +} + async function registerSpotify(asUrl: string): Promise<SpotifyManifest> { const { readFileSync: rfs } = await import("node:fs"); const { join: pjoin, dirname: pdir } = await import("node:path"); @@ -231,6 +382,25 @@ async function registerSpotify(asUrl: string): Promise<SpotifyManifest> { return manifest; } +async function seedSpotifyOwnerConnection(manifest: SpotifyManifest): Promise<string> { + const connectorId = canonicalConnectorKey(manifest.connector_id) ?? manifest.connector_id; + const connectorInstanceId = `cin_agent_${connectorId}`; + const now = new Date().toISOString(); + await createRequestConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId, + createdAt: now, + displayName: `${manifest.connector_id} test account`, + ownerSubjectId: "owner_local", + sourceBinding: { fixture: connectorInstanceId }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); + return connectorInstanceId; +} + // ─── cache unit tests ───────────────────────────────────────────────────────── test("cache: writeAccess / readAccess round-trips without token material", async () => { @@ -378,6 +548,7 @@ test("agent-flow: register client, stage PAR, approve inline, store token, verif try { const spotifyManifest = await registerSpotify(asUrl); + await seedSpotifyOwnerConnection(spotifyManifest); await ensureCacheDirs(cacheRoot); writeAccess(cacheRoot, { as_url: asUrl, rs_url: rsUrl }); @@ -422,7 +593,7 @@ test("agent-flow: register client, stage PAR, approve inline, store token, verif // Introspect to get grant metadata (mirrors what "pdpp agent store" does) const introspResp = await fetch(`${asUrl}/introspect`, { body: JSON.stringify({ token: approval.token }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); const introspection = (await introspResp.json()) as { @@ -567,6 +738,7 @@ test("agent-connect: owner approval completes polling without exposing owner tok tokenUrl: start.token_url, }); assert.equal(completedPoll.resp.status, 200); + assertCredentialNoStoreHeaders(completedPoll.resp); assert.equal(completedPoll.body.token_type, "Bearer"); assert.equal(typeof completedPoll.body.access_token, "string"); assert.equal(typeof completedPoll.body.grant_id, "string"); @@ -580,8 +752,1037 @@ test("agent-connect: owner approval completes polling without exposing owner tok pollingCode: start.polling_code, tokenUrl: start.token_url, }); - assert.equal(replayPoll.resp.status, 401); - assert.equal(errorCode(replayPoll.body), "invalid_grant"); + assert.equal(replayPoll.resp.status, 200, "response-loss retry returns the retained token envelope"); + assertCredentialNoStoreHeaders(replayPoll.resp); + assert.equal(replayPoll.body.access_token, completedPoll.body.access_token); + + await createAgentConnectRequest({ asUrl, clientName: "Agent Connect Prune Trigger" }); + const afterPrunePoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(afterPrunePoll.resp.status, 200, "unrelated registration must not delete retained response"); + assertCredentialNoStoreHeaders(afterPrunePoll.resp); + assert.equal(afterPrunePoll.body.access_token, completedPoll.body.access_token); + } finally { + await closeServer(server); + } +}); + +test("agent-connect: registration 201 carries credential no-store headers", async () => { + const { server, asUrl } = await spinUpServer(); + try { + const { start } = await createAgentConnectRequest({ asUrl, clientName: "Agent Connect Cache Headers" }); + assert.equal(start.status, "pending"); + } finally { + await closeServer(server); + } +}); + +test("agent-connect: approved handoff survives AS restart before polling", async () => { + const dbPath = join(makeTemporaryDir("pdpp-agent-connect-restart-"), "reference.sqlite"); + const first = await spinUpServer({ dbPath }); + let restarted: Awaited<ReturnType<typeof spinUpServer>> | null = null; + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl: first.asUrl, + clientName: "Agent Connect Restart Test", + }); + await approveInline({ + asUrl: first.asUrl, + requestUri: staged.request_uri, + subjectId: "owner_local", + }); + await closeServer(first.server); + + restarted = await spinUpServer({ dbPath }); + const completedPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: rewriteUrlOrigin(start.token_url, restarted.asUrl), + }); + assert.equal(completedPoll.resp.status, 200); + assert.equal(completedPoll.body.token_type, "Bearer"); + assert.equal(typeof completedPoll.body.access_token, "string"); + } finally { + if (restarted) { + await closeServer(restarted.server); + } + } +}); + +test("agent-connect: approval committed before completion recovers at poll time", async () => { + const { server, asUrl } = await spinUpServer(); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl, + clientName: "Agent Connect Crash Seam Test", + }); + let tripped = false; + __setAgentConnectCompleteFailureForTest(() => { + if (!tripped) { + tripped = true; + throw new Error("agent-connect completion seam crash"); + } + }); + await assert.rejects( + approveInline({ asUrl, requestUri: staged.request_uri, subjectId: "owner_local" }), + COMPLETION_SEAM_CRASH_PATTERN + ); + __setAgentConnectCompleteFailureForTest(null); + + const completedPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(completedPoll.resp.status, 200); + assert.equal(completedPoll.body.token_type, "Bearer"); + assert.equal(typeof completedPoll.body.access_token, "string"); + } finally { + __setAgentConnectCompleteFailureForTest(null); + await closeServer(server); + } +}); + +test("agent-connect: crash-completed approval that expires before poll revokes committed token", async () => { + const { server, asUrl } = await spinUpServer({ agentConnectTtlMs: 1 }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl, + clientName: "Agent Connect Crash Expire SQLite", + }); + let tripped = false; + __setAgentConnectCompleteFailureForTest(() => { + if (!tripped) { + tripped = true; + throw new Error("agent-connect completion seam crash"); + } + }); + await assert.rejects( + approveInline({ asUrl, requestUri: staged.request_uri, subjectId: "owner_local" }), + COMPLETION_SEAM_CRASH_PATTERN + ); + __setAgentConnectCompleteFailureForTest(null); + const committedToken = sqliteCommittedTokenForRequestUri(staged.request_uri); + assert.equal(await tokenIsIntrospectionActive(asUrl, committedToken), true); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const expiredPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(expiredPoll.resp.status, 400); + assert.equal(errorCode(expiredPoll.body), "expired_token"); + assert.equal(await tokenIsIntrospectionActive(asUrl, committedToken), false); + } finally { + __setAgentConnectCompleteFailureForTest(null); + await closeServer(server); + } +}); + +test("agent-connect: prune reconciles crash-completed expired approval before deleting attempt", async () => { + const { server, asUrl } = await spinUpServer({ agentConnectTtlMs: 1 }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl, + clientName: "Agent Connect Crash Prune SQLite", + }); + let tripped = false; + __setAgentConnectCompleteFailureForTest(() => { + if (!tripped) { + tripped = true; + throw new Error("agent-connect completion seam crash"); + } + }); + await assert.rejects( + approveInline({ asUrl, requestUri: staged.request_uri, subjectId: "owner_local" }), + COMPLETION_SEAM_CRASH_PATTERN + ); + __setAgentConnectCompleteFailureForTest(null); + const committedToken = sqliteCommittedTokenForRequestUri(staged.request_uri); + assert.equal(await tokenIsIntrospectionActive(asUrl, committedToken), true); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await createAgentConnectRequest({ asUrl, clientName: "Agent Connect Crash Prune Trigger" }); + assert.equal(await tokenIsIntrospectionActive(asUrl, committedToken), false); + const prunedPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(prunedPoll.resp.status, 400); + assert.equal(errorCode(prunedPoll.body), "expired_token"); + } finally { + __setAgentConnectCompleteFailureForTest(null); + await closeServer(server); + } +}); + +test("agent-connect: cleanup miss racing approval commit revokes the committed token", async () => { + const { server, asUrl } = await spinUpServer({ agentConnectTtlMs: 1 }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl, + clientName: "Agent Connect Cleanup Race SQLite", + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const pause = createCleanupPause(); + __setAgentConnectCleanupAfterMissForTest(pause.hook); + const pollPromise = pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + await pause.paused; + + const approval = await approveInline({ + asUrl, + requestUri: staged.request_uri, + subjectId: "owner_local", + }); + assert.equal(await tokenIsIntrospectionActive(asUrl, approval.token), true); + pause.release(); + const expiredPoll = await pollPromise; + assert.equal(expiredPoll.resp.status, 400); + assert.equal(errorCode(expiredPoll.body), "expired_token"); + assert.equal(await tokenIsIntrospectionActive(asUrl, approval.token), false); + } finally { + __setAgentConnectCleanupAfterMissForTest(null); + await closeServer(server); + } +}); + +test("agent-connect: approval after cleanup second miss before tombstone is revoked", async () => { + const { server, asUrl } = await spinUpServer({ agentConnectTtlMs: 1 }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl, + clientName: "Agent Connect Cleanup Second Miss SQLite", + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const pause = createPause(); + __setAgentConnectCleanupBeforeExpireForTest(pause.hook); + const pollPromise = pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + await pause.paused; + + const approval = await approveInline({ + asUrl, + requestUri: staged.request_uri, + subjectId: "owner_local", + }); + assert.equal(await tokenIsIntrospectionActive(asUrl, approval.token), true); + pause.release(); + const expiredPoll = await pollPromise; + assert.equal(expiredPoll.resp.status, 400); + assert.equal(errorCode(expiredPoll.body), "expired_token"); + assert.equal(await tokenIsIntrospectionActive(asUrl, approval.token), false); + } finally { + __setAgentConnectCleanupBeforeExpireForTest(null); + await closeServer(server); + } +}); + +test("agent-connect: approval completion after tombstone revokes its token", async () => { + const { server, asUrl } = await spinUpServer({ agentConnectTtlMs: 1 }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl, + clientName: "Agent Connect Tombstone Then Complete SQLite", + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const pause = createPause(); + __setAgentConnectCompleteBeforeMarkForTest(pause.hook); + const approvalPromise = approveInline({ + asUrl, + requestUri: staged.request_uri, + subjectId: "owner_local", + }); + await pause.paused; + + const expiredPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(expiredPoll.resp.status, 400); + assert.equal(errorCode(expiredPoll.body), "expired_token"); + pause.release(); + const approval = await approvalPromise; + assert.equal(await tokenIsIntrospectionActive(asUrl, approval.token), false); + } finally { + __setAgentConnectCompleteBeforeMarkForTest(null); + await closeServer(server); + } +}); + +test("agent-connect: prune reconciles more than one expired SQLite batch", async () => { + const { server, asUrl } = await spinUpServer(); + try { + const expiredAt = Date.now() - 1; + const createdAt = new Date(expiredAt - 1).toISOString(); + for (let index = 0; index < 1001; index += 1) { + dbExec(referenceQueries.authAgentConnectAttemptsInsert, [ + `expired-batch-${index}`, + `urn:ietf:params:oauth:request_uri:expired-batch-${index}`, + null, + `expired-batch-hash-${index}`, + `${asUrl}/consent?request_uri=expired-batch-${index}`, + `${asUrl}/token`, + createdAt, + expiredAt, + ]); + } + + await Promise.race([ + createAgentConnectRequest({ asUrl, clientName: "Agent Connect 1001 Prune Trigger" }), + new Promise<never>((_, reject) => { + setTimeout(() => reject(new Error("agent-connect historic tombstone pruning timed out")), 2000); + }), + ]); + assert.equal(sqliteAgentConnectAttemptCountByStatus("expired"), 0); + } finally { + await closeServer(server); + } +}); + +test("agent-connect: retained tombstones do not starve a later collectible SQLite volume", async () => { + const { server, asUrl } = await spinUpServer(); + try { + const expiredAt = Date.now() - 1; + const createdAt = new Date(expiredAt - 1).toISOString(); + const retainedDeviceCode = "retained-tombstone-sqlite"; + const retainedRequestUri = `urn:pdpp:pending-consent:${retainedDeviceCode}`; + dbExec(referenceQueries.authPendingConsentsInsert, [ + retainedDeviceCode, + "RETAINED-SQLITE", + "{}", + null, + null, + null, + createdAt, + new Date(Date.now() + 60_000).toISOString(), + null, + ]); + dbExec(referenceQueries.authPendingConsentsMarkApproved, [ + "owner_local", + "retained-grant-sqlite", + "retained-token-sqlite", + null, + createdAt, + retainedDeviceCode, + ]); + for (let index = 0; index < 1001; index += 1) { + if (index < 1000) { + dbExec(referenceQueries.authAgentConnectAttemptsInsert, [ + `retained-tombstone-${index}`, + retainedRequestUri, + null, + `retained-tombstone-hash-${index}`, + `${asUrl}/consent?request_uri=retained-tombstone-${index}`, + `${asUrl}/token`, + createdAt, + expiredAt, + ]); + dbExec(referenceQueries.authAgentConnectAttemptsMarkExpiredById, [createdAt, `retained-tombstone-${index}`]); + } + dbExec(referenceQueries.authAgentConnectAttemptsInsert, [ + `collectible-tombstone-${index}`, + `urn:pdpp:pending-consent:missing-tombstone-${index}`, + null, + `collectible-tombstone-hash-${index}`, + `${asUrl}/consent?request_uri=collectible-tombstone-${index}`, + `${asUrl}/token`, + createdAt, + expiredAt, + ]); + dbExec(referenceQueries.authAgentConnectAttemptsMarkExpiredById, [createdAt, `collectible-tombstone-${index}`]); + } + assert.equal(sqliteAgentConnectAttemptCountByStatus("expired"), 2001); + + await createAgentConnectRequest({ asUrl, clientName: "Agent Connect Tombstone GC Trigger" }); + assert.equal(sqliteAgentConnectAttemptCountByStatus("expired"), 1000); + } finally { + await closeServer(server); + } +}); + +test("agent-connect: natural pending-consent expiry enables tombstone GC", async () => { + const { server, asUrl } = await spinUpServer(); + try { + const expiredAt = Date.now() - 1; + const createdAt = new Date(expiredAt - 1).toISOString(); + const deviceCode = "natural-timeout-sqlite"; + dbExec(referenceQueries.authPendingConsentsInsert, [ + deviceCode, + "NATURAL-TIMEOUT-SQLITE", + "{}", + null, + null, + null, + createdAt, + new Date(expiredAt).toISOString(), + null, + ]); + dbExec(referenceQueries.authAgentConnectAttemptsInsert, [ + "natural-timeout-tombstone-sqlite", + `urn:pdpp:pending-consent:${deviceCode}`, + null, + "natural-timeout-hash-sqlite", + `${asUrl}/consent`, + `${asUrl}/token`, + createdAt, + expiredAt, + ]); + dbExec(referenceQueries.authAgentConnectAttemptsMarkExpiredById, [createdAt, "natural-timeout-tombstone-sqlite"]); + assert.equal( + getOne<{ status?: string }>(referenceQueries.authPendingConsentsGetByDeviceCode, [deviceCode])?.status, + "pending" + ); + + await createAgentConnectRequest({ asUrl, clientName: "Agent Connect Tombstone Terminal Trigger" }); + assert.equal(sqliteAgentConnectAttemptCountByStatus("expired"), 0); + assert.equal( + getOne<{ status?: string }>(referenceQueries.authPendingConsentsGetByDeviceCode, [deviceCode])?.status, + "expired" + ); + } finally { + await closeServer(server); + } +}); + +test("agent-connect: expired same-request attempt does not revoke valid staggered attempt", async () => { + const { server, asUrl } = await spinUpServer({ agentConnectTtlMs: 60_000 }); + try { + const first = await createAgentConnectRequest({ + asUrl, + clientName: "Agent Connect Staggered Old SQLite", + }); + setSqliteAgentConnectAttemptExpiresAt(first.start.id, Date.now() - 1); + const second = await fetch(`${asUrl}/agent-connect`, { + body: JSON.stringify({ + client_id: first.registered.client_id, + request_uri: first.staged.request_uri, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const secondStart = (await second.json()) as AgentConnectStart; + assert.equal(second.status, 201, JSON.stringify(secondStart)); + + await approveInline({ + asUrl, + requestUri: first.staged.request_uri, + subjectId: "owner_local", + }); + const oldPoll = await pollAgentConnectToken({ + pollingCode: first.start.polling_code, + tokenUrl: first.start.token_url, + }); + assert.equal(oldPoll.resp.status, 400); + assert.equal(errorCode(oldPoll.body), "expired_token"); + + const newPoll = await pollAgentConnectToken({ + pollingCode: secondStart.polling_code, + tokenUrl: secondStart.token_url, + }); + assert.equal(newPoll.resp.status, 200, JSON.stringify(newPoll.body)); + const deliveredToken = newPoll.body.access_token; + assert.ok(deliveredToken); + assert.equal(await tokenIsIntrospectionActive(asUrl, deliveredToken), true); + } finally { + await closeServer(server); + } +}); + +test("agent-connect: registration rechecks durable pending consent after approval", async () => { + const { server, asUrl } = await spinUpServer({ agentConnectTtlMs: 60_000 }); + try { + const first = await createAgentConnectRequest({ + asUrl, + clientName: "Agent Connect Registration CAS SQLite", + }); + const pause = createPause(); + __setAgentConnectCreateBeforePersistForTest(pause.hook); + const lateRegistration = fetch(`${asUrl}/agent-connect`, { + body: JSON.stringify({ + client_id: first.registered.client_id, + request_uri: first.staged.request_uri, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + await pause.paused; + const approval = await approveInline({ + asUrl, + requestUri: first.staged.request_uri, + subjectId: "owner_local", + }); + assert.equal(await tokenIsIntrospectionActive(asUrl, approval.token), true); + + pause.release(); + const late = await lateRegistration; + assert.equal(late.status, 400); + const firstPoll = await pollAgentConnectToken({ + pollingCode: first.start.polling_code, + tokenUrl: first.start.token_url, + }); + assert.equal(firstPoll.resp.status, 200, JSON.stringify(firstPoll.body)); + } finally { + __setAgentConnectCreateBeforePersistForTest(null); + await closeServer(server); + } +}); + +test("agent-connect: live Postgres approved handoff survives AS restart before polling", async (t) => { + const baseUrl = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); + if (!baseUrl) { + t.skip("PDPP_TEST_POSTGRES_URL must target the dedicated local Postgres test listener"); + return; + } + + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: baseUrl, + databaseName: "pdpp_test_agent_connect_durable", + }, + async (databaseUrl) => { + const opts = { databaseUrl, storageBackend: "postgres" as const }; + const first = await spinUpServer(opts); + let firstClosed = false; + let restarted: Awaited<ReturnType<typeof spinUpServer>> | null = null; + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl: first.asUrl, + clientName: "Agent Connect Postgres Restart Test", + }); + await approveInline({ + asUrl: first.asUrl, + requestUri: staged.request_uri, + subjectId: "owner_local", + }); + await closeServer(first.server); + firstClosed = true; + await closePostgresStorage(); + + restarted = await spinUpServer(opts); + const completedPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: rewriteUrlOrigin(start.token_url, restarted.asUrl), + }); + assert.equal(completedPoll.resp.status, 200); + assert.equal(completedPoll.body.token_type, "Bearer"); + assert.equal(typeof completedPoll.body.access_token, "string"); + } finally { + if (!firstClosed) { + await closeServer(first.server); + } + if (restarted) { + await closeServer(restarted.server); + } + await closePostgresStorage(); + } + } + ); +}); + +test("agent-connect: live Postgres response-loss retry survives unrelated registration", async (t) => { + const baseUrl = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); + if (!baseUrl) { + t.skip("PDPP_TEST_POSTGRES_URL must target the dedicated local Postgres test listener"); + return; + } + + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: baseUrl, + databaseName: "pdpp_test_agent_connect_response_loss_prune", + }, + async (databaseUrl) => { + const { server, asUrl } = await spinUpServer({ databaseUrl, storageBackend: "postgres" }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl, + clientName: "Agent Connect PG Response Loss A", + }); + await approveInline({ asUrl, requestUri: staged.request_uri, subjectId: "owner_local" }); + const firstPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(firstPoll.resp.status, 200); + assertCredentialNoStoreHeaders(firstPoll.resp); + + await createAgentConnectRequest({ asUrl, clientName: "Agent Connect PG Response Loss B" }); + const retryPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(retryPoll.resp.status, 200); + assertCredentialNoStoreHeaders(retryPoll.resp); + assert.equal(retryPoll.body.access_token, firstPoll.body.access_token); + } finally { + await closeServer(server); + await closePostgresStorage(); + } + } + ); +}); + +test("agent-connect: live Postgres crash-completed expiry and prune revoke committed tokens", async (t) => { + const baseUrl = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); + if (!baseUrl) { + t.skip("PDPP_TEST_POSTGRES_URL must target the dedicated local Postgres test listener"); + return; + } + + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: baseUrl, + databaseName: "pdpp_test_agent_connect_crash_expire_prune", + }, + async (databaseUrl) => { + const expireServer = await spinUpServer({ agentConnectTtlMs: 1, databaseUrl, storageBackend: "postgres" }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl: expireServer.asUrl, + clientName: "Agent Connect PG Crash Expire", + }); + let tripped = false; + __setAgentConnectCompleteFailureForTest(() => { + if (!tripped) { + tripped = true; + throw new Error("agent-connect completion seam crash"); + } + }); + await assert.rejects( + approveInline({ asUrl: expireServer.asUrl, requestUri: staged.request_uri, subjectId: "owner_local" }), + COMPLETION_SEAM_CRASH_PATTERN + ); + __setAgentConnectCompleteFailureForTest(null); + const committedToken = await postgresCommittedTokenForRequestUri(staged.request_uri); + assert.equal(await tokenIsIntrospectionActive(expireServer.asUrl, committedToken), true); + await new Promise((resolve) => setTimeout(resolve, 10)); + const expiredPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(expiredPoll.resp.status, 400); + assert.equal(errorCode(expiredPoll.body), "expired_token"); + assert.equal(await tokenIsIntrospectionActive(expireServer.asUrl, committedToken), false); + } finally { + __setAgentConnectCompleteFailureForTest(null); + await closeServer(expireServer.server); + await closePostgresStorage(); + } + + const pruneServer = await spinUpServer({ agentConnectTtlMs: 1, databaseUrl, storageBackend: "postgres" }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl: pruneServer.asUrl, + clientName: "Agent Connect PG Crash Prune", + }); + let tripped = false; + __setAgentConnectCompleteFailureForTest(() => { + if (!tripped) { + tripped = true; + throw new Error("agent-connect completion seam crash"); + } + }); + await assert.rejects( + approveInline({ asUrl: pruneServer.asUrl, requestUri: staged.request_uri, subjectId: "owner_local" }), + COMPLETION_SEAM_CRASH_PATTERN + ); + __setAgentConnectCompleteFailureForTest(null); + const committedToken = await postgresCommittedTokenForRequestUri(staged.request_uri); + assert.equal(await tokenIsIntrospectionActive(pruneServer.asUrl, committedToken), true); + await new Promise((resolve) => setTimeout(resolve, 10)); + await createAgentConnectRequest({ asUrl: pruneServer.asUrl, clientName: "Agent Connect PG Prune Trigger" }); + assert.equal(await tokenIsIntrospectionActive(pruneServer.asUrl, committedToken), false); + const prunedPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(prunedPoll.resp.status, 400); + assert.equal(errorCode(prunedPoll.body), "expired_token"); + } finally { + __setAgentConnectCompleteFailureForTest(null); + await closeServer(pruneServer.server); + await closePostgresStorage(); + } + } + ); +}); + +test("agent-connect: live Postgres cleanup miss racing approval commit revokes committed token", async (t) => { + const baseUrl = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); + if (!baseUrl) { + t.skip("PDPP_TEST_POSTGRES_URL must target the dedicated local Postgres test listener"); + return; + } + + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: baseUrl, + databaseName: "pdpp_test_agent_connect_cleanup_race", + }, + async (databaseUrl) => { + const { server, asUrl } = await spinUpServer({ agentConnectTtlMs: 1, databaseUrl, storageBackend: "postgres" }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl, + clientName: "Agent Connect PG Cleanup Race", + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const pause = createCleanupPause(); + __setAgentConnectCleanupAfterMissForTest(pause.hook); + const pollPromise = pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + await pause.paused; + + const approval = await approveInline({ + asUrl, + requestUri: staged.request_uri, + subjectId: "owner_local", + }); + assert.equal(await tokenIsIntrospectionActive(asUrl, approval.token), true); + pause.release(); + const expiredPoll = await pollPromise; + assert.equal(expiredPoll.resp.status, 400); + assert.equal(errorCode(expiredPoll.body), "expired_token"); + assert.equal(await tokenIsIntrospectionActive(asUrl, approval.token), false); + } finally { + __setAgentConnectCleanupAfterMissForTest(null); + await closeServer(server); + await closePostgresStorage(); + } + } + ); +}); + +test("agent-connect: live Postgres expiry CAS interleavings revoke committed tokens", async (t) => { + const baseUrl = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); + if (!baseUrl) { + t.skip("PDPP_TEST_POSTGRES_URL must target the dedicated local Postgres test listener"); + return; + } + + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: baseUrl, + databaseName: "pdpp_test_agent_connect_expiry_cas", + }, + async (databaseUrl) => { + const secondMissServer = await spinUpServer({ agentConnectTtlMs: 1, databaseUrl, storageBackend: "postgres" }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl: secondMissServer.asUrl, + clientName: "Agent Connect PG Cleanup Second Miss", + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const pause = createPause(); + __setAgentConnectCleanupBeforeExpireForTest(pause.hook); + const pollPromise = pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + await pause.paused; + const approval = await approveInline({ + asUrl: secondMissServer.asUrl, + requestUri: staged.request_uri, + subjectId: "owner_local", + }); + assert.equal(await tokenIsIntrospectionActive(secondMissServer.asUrl, approval.token), true); + pause.release(); + const expiredPoll = await pollPromise; + assert.equal(expiredPoll.resp.status, 400); + assert.equal(errorCode(expiredPoll.body), "expired_token"); + assert.equal(await tokenIsIntrospectionActive(secondMissServer.asUrl, approval.token), false); + } finally { + __setAgentConnectCleanupBeforeExpireForTest(null); + await closeServer(secondMissServer.server); + await closePostgresStorage(); + } + + const tombstoneServer = await spinUpServer({ agentConnectTtlMs: 1, databaseUrl, storageBackend: "postgres" }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl: tombstoneServer.asUrl, + clientName: "Agent Connect PG Tombstone Then Complete", + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const pause = createPause(); + __setAgentConnectCompleteBeforeMarkForTest(pause.hook); + const approvalPromise = approveInline({ + asUrl: tombstoneServer.asUrl, + requestUri: staged.request_uri, + subjectId: "owner_local", + }); + await pause.paused; + const expiredPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(expiredPoll.resp.status, 400); + assert.equal(errorCode(expiredPoll.body), "expired_token"); + pause.release(); + const approval = await approvalPromise; + assert.equal(await tokenIsIntrospectionActive(tombstoneServer.asUrl, approval.token), false); + } finally { + __setAgentConnectCompleteBeforeMarkForTest(null); + await closeServer(tombstoneServer.server); + await closePostgresStorage(); + } + } + ); +}); + +test("agent-connect: live Postgres tombstone GC and staggered same-request delivery", async (t) => { + const baseUrl = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); + if (!baseUrl) { + t.skip("PDPP_TEST_POSTGRES_URL must target the dedicated local Postgres test listener"); + return; + } + + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: baseUrl, + databaseName: "pdpp_test_agent_connect_gc_staggered", + }, + async (databaseUrl) => { + const gcServer = await spinUpServer({ databaseUrl, storageBackend: "postgres" }); + try { + const expiredAt = Date.now() - 1; + const createdAt = new Date(expiredAt - 1).toISOString(); + const retainedDeviceCode = "retained-tombstone-postgres"; + const retainedRequestUri = `urn:pdpp:pending-consent:${retainedDeviceCode}`; + await postgresQuery( + `INSERT INTO pending_consents( + device_code, user_code, params_json, status, subject_id, grant_id, token_id, + created_at, expires_at, approved_at + ) VALUES($1, $2, $3::jsonb, 'approved', $4, $5, $6, $7, $8, $7)`, + [ + retainedDeviceCode, + "RETAINED-POSTGRES", + "{}", + "owner_local", + "retained-grant-postgres", + "retained-token-postgres", + createdAt, + new Date(Date.now() + 60_000).toISOString(), + ] + ); + await Promise.all( + Array.from({ length: 1001 }, async (_, index) => { + if (index < 1000) { + await postgresQuery( + `INSERT INTO agent_connect_attempts( + id, request_uri, client_id, polling_code_hash, status, approval_url, token_url, + interval_seconds, created_at, expires_at_ms, completed_at + ) VALUES($1, $2, NULL, $3, 'expired', $4, $5, 2, $6, $7, $6)`, + [ + `pg-retained-tombstone-${index}`, + retainedRequestUri, + `pg-retained-tombstone-hash-${index}`, + `${gcServer.asUrl}/consent?request_uri=pg-retained-tombstone-${index}`, + `${gcServer.asUrl}/token`, + createdAt, + expiredAt, + ] + ); + } + await postgresQuery( + `INSERT INTO agent_connect_attempts( + id, request_uri, client_id, polling_code_hash, status, approval_url, token_url, + interval_seconds, created_at, expires_at_ms, completed_at + ) VALUES($1, $2, NULL, $3, 'expired', $4, $5, 2, $6, $7, $6)`, + [ + `pg-collectible-tombstone-${index}`, + `urn:pdpp:pending-consent:pg-missing-tombstone-${index}`, + `pg-collectible-tombstone-hash-${index}`, + `${gcServer.asUrl}/consent?request_uri=pg-collectible-tombstone-${index}`, + `${gcServer.asUrl}/token`, + createdAt, + expiredAt, + ] + ); + }) + ); + assert.equal(await postgresAgentConnectAttemptCountByStatus("expired"), 2001); + await createAgentConnectRequest({ + asUrl: gcServer.asUrl, + clientName: "Agent Connect PG Tombstone GC Trigger", + }); + assert.equal(await postgresAgentConnectAttemptCountByStatus("expired"), 1000); + + const naturalDeviceCode = "natural-timeout-postgres"; + await postgresQuery( + `INSERT INTO pending_consents( + device_code, user_code, params_json, status, created_at, expires_at + ) VALUES($1, $2, $3::jsonb, 'pending', $4, $5)`, + [naturalDeviceCode, "NATURAL-TIMEOUT-POSTGRES", "{}", createdAt, new Date(expiredAt).toISOString()] + ); + await postgresQuery( + `INSERT INTO agent_connect_attempts( + id, request_uri, client_id, polling_code_hash, status, approval_url, token_url, + interval_seconds, created_at, expires_at_ms, completed_at + ) VALUES($1, $2, NULL, $3, 'expired', $4, $5, 2, $6, $7, $6)`, + [ + "pg-natural-timeout-tombstone", + `urn:pdpp:pending-consent:${naturalDeviceCode}`, + "pg-natural-timeout-hash", + `${gcServer.asUrl}/consent`, + `${gcServer.asUrl}/token`, + createdAt, + expiredAt, + ] + ); + assert.equal( + ( + await postgresQuery<{ status?: string }>("SELECT status FROM pending_consents WHERE device_code = $1", [ + naturalDeviceCode, + ]) + ).rows[0]?.status, + "pending" + ); + await createAgentConnectRequest({ + asUrl: gcServer.asUrl, + clientName: "Agent Connect PG Tombstone Natural Timeout Trigger", + }); + assert.equal(await postgresAgentConnectAttemptCountByStatus("expired"), 1000); + assert.equal( + ( + await postgresQuery<{ status?: string }>("SELECT status FROM pending_consents WHERE device_code = $1", [ + naturalDeviceCode, + ]) + ).rows[0]?.status, + "expired" + ); + } finally { + await closeServer(gcServer.server); + await closePostgresStorage(); + } + + const staggeredServer = await spinUpServer({ + agentConnectTtlMs: 60_000, + databaseUrl, + storageBackend: "postgres", + }); + try { + const first = await createAgentConnectRequest({ + asUrl: staggeredServer.asUrl, + clientName: "Agent Connect PG Staggered Old", + }); + await setPostgresAgentConnectAttemptExpiresAt(first.start.id, Date.now() - 1); + const second = await fetch(`${staggeredServer.asUrl}/agent-connect`, { + body: JSON.stringify({ + client_id: first.registered.client_id, + request_uri: first.staged.request_uri, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const secondStart = (await second.json()) as AgentConnectStart; + assert.equal(second.status, 201, JSON.stringify(secondStart)); + await approveInline({ + asUrl: staggeredServer.asUrl, + requestUri: first.staged.request_uri, + subjectId: "owner_local", + }); + const oldPoll = await pollAgentConnectToken({ + pollingCode: first.start.polling_code, + tokenUrl: first.start.token_url, + }); + assert.equal(oldPoll.resp.status, 400); + assert.equal(errorCode(oldPoll.body), "expired_token"); + + const newPoll = await pollAgentConnectToken({ + pollingCode: secondStart.polling_code, + tokenUrl: secondStart.token_url, + }); + assert.equal(newPoll.resp.status, 200, JSON.stringify(newPoll.body)); + const deliveredToken = newPoll.body.access_token; + assert.ok(deliveredToken); + assert.equal(await tokenIsIntrospectionActive(staggeredServer.asUrl, deliveredToken), true); + } finally { + await closeServer(staggeredServer.server); + await closePostgresStorage(); + } + + const lateRaceServer = await spinUpServer({ + agentConnectTtlMs: 60_000, + databaseUrl, + storageBackend: "postgres", + }); + try { + const first = await createAgentConnectRequest({ + asUrl: lateRaceServer.asUrl, + clientName: "Agent Connect PG Registration CAS", + }); + const pause = createPause(); + __setAgentConnectCreateBeforePersistForTest(pause.hook); + const lateRegistration = fetch(`${lateRaceServer.asUrl}/agent-connect`, { + body: JSON.stringify({ + client_id: first.registered.client_id, + request_uri: first.staged.request_uri, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + await pause.paused; + const approval = await approveInline({ + asUrl: lateRaceServer.asUrl, + requestUri: first.staged.request_uri, + subjectId: "owner_local", + }); + assert.equal(await tokenIsIntrospectionActive(lateRaceServer.asUrl, approval.token), true); + pause.release(); + const late = await lateRegistration; + assert.equal(late.status, 400); + const firstPoll = await pollAgentConnectToken({ + pollingCode: first.start.polling_code, + tokenUrl: first.start.token_url, + }); + assert.equal(firstPoll.resp.status, 200, JSON.stringify(firstPoll.body)); + } finally { + __setAgentConnectCreateBeforePersistForTest(null); + await closeServer(lateRaceServer.server); + await closePostgresStorage(); + } + } + ); +}); + +test("agent-connect: second registration and concurrent polling are idempotent", async () => { + const { server, asUrl } = await spinUpServer(); + try { + const { staged, start } = await createAgentConnectRequest({ asUrl, clientName: "Agent Connect Concurrent A" }); + const secondResp = await fetch(`${asUrl}/agent-connect`, { + body: JSON.stringify({ request_uri: staged.request_uri }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const second = (await secondResp.json()) as AgentConnectStart; + assert.equal(secondResp.status, 201); + assert.notEqual(second.polling_code, start.polling_code); + + await approveInline({ asUrl, requestUri: staged.request_uri, subjectId: "owner_local" }); + const [firstPoll, secondPoll] = await Promise.all([ + pollAgentConnectToken({ pollingCode: start.polling_code, tokenUrl: start.token_url }), + pollAgentConnectToken({ pollingCode: second.polling_code, tokenUrl: second.token_url }), + ]); + assert.equal(firstPoll.resp.status, 200); + assert.equal(secondPoll.resp.status, 200); + const replayPoll = await pollAgentConnectToken({ pollingCode: start.polling_code, tokenUrl: start.token_url }); + assert.equal(replayPoll.resp.status, 200); + assert.equal(replayPoll.body.access_token, firstPoll.body.access_token); + assert.equal(secondPoll.body.access_token, firstPoll.body.access_token); } finally { await closeServer(server); } @@ -638,6 +1839,183 @@ test("agent-connect: owner denial returns bounded access_denied", async () => { } }); +test("agent-connect: approval_id denial projects to polling", async () => { + const { server, asUrl } = await spinUpServer(); + try { + const { staged, start } = await createAgentConnectRequest({ asUrl, clientName: "Agent Connect Approval ID Deny" }); + const approvalId = sqliteApprovalIdForRequestUri(staged.request_uri); + const denyResp = await fetch(`${asUrl}/consent/deny`, { + body: new URLSearchParams({ approval_id: approvalId }).toString(), + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(denyResp.status, 200); + const deniedPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(deniedPoll.resp.status, 403); + assert.equal(errorCode(deniedPoll.body), "access_denied"); + assert.doesNotMatch(JSON.stringify(deniedPoll.body), DENIED_POLL_SECRET_PATTERN); + } finally { + await closeServer(server); + } +}); + +test("agent-connect: denial completion failure is reconciled during polling", async () => { + const dbPath = join(makeTemporaryDir("pdpp-agent-connect-denial-restart-"), "reference.sqlite"); + const first = await spinUpServer({ dbPath }); + let restarted: Awaited<ReturnType<typeof spinUpServer>> | null = null; + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl: first.asUrl, + clientName: "Agent Connect Deny Recovery", + }); + __setAgentConnectCompleteFailureForTest(() => { + throw new Error("denial completion seam crash"); + }); + try { + const denyResp = await fetch(`${first.asUrl}/consent/deny`, { + body: new URLSearchParams({ request_uri: staged.request_uri }).toString(), + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(denyResp.status, 500); + } finally { + __setAgentConnectCompleteFailureForTest(null); + } + const deviceCode = parsePendingConsentRequestUri(staged.request_uri); + assert.ok(deviceCode); + assert.equal( + getOne<{ token_id?: string | null }>(referenceQueries.authPendingConsentsGetByDeviceCode, [deviceCode])?.token_id, + null + ); + await closeServer(first.server); + restarted = await spinUpServer({ dbPath }); + const retryDeny = await fetch(`${restarted.asUrl}/consent/deny`, { + body: new URLSearchParams({ request_uri: staged.request_uri }).toString(), + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(retryDeny.status, 404, "a committed denial is not duplicated after restart"); + const deniedPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: rewriteUrlOrigin(start.token_url, restarted.asUrl), + }); + assert.equal(deniedPoll.resp.status, 403); + assert.equal(errorCode(deniedPoll.body), "access_denied"); + assert.doesNotMatch(JSON.stringify(deniedPoll.body), DENIED_POLL_SECRET_PATTERN); + } finally { + __setAgentConnectCompleteFailureForTest(null); + if (restarted) { + await closeServer(restarted.server); + } else { + await closeServer(first.server); + } + } +}); + +test("agent-connect: expired consent projects to bounded expired_token polling", async () => { + const { server, asUrl } = await spinUpServer(); + try { + const { staged, start } = await createAgentConnectRequest({ asUrl, clientName: "Agent Connect Consent Expiry" }); + const deviceCode = parsePendingConsentRequestUri(staged.request_uri); + assert.ok(deviceCode); + dbExec(referenceQueries.authPendingConsentsMarkExpired, [deviceCode]); + const expiredPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(expiredPoll.resp.status, 400); + assert.equal(errorCode(expiredPoll.body), "expired_token"); + assert.doesNotMatch(JSON.stringify(expiredPoll.body), EXPIRED_POLL_SECRET_PATTERN); + } finally { + await closeServer(server); + } +}); + +test("agent-connect: live Postgres denial projects and recovers after completion failure", async (t) => { + const baseUrl = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); + if (!baseUrl) { + t.skip("PDPP_TEST_POSTGRES_URL must target the dedicated local Postgres test listener"); + return; + } + + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: baseUrl, + databaseName: "pdpp_test_agent_connect_denial_recovery", + }, + async (databaseUrl) => { + const first = await spinUpServer({ databaseUrl, storageBackend: "postgres" }); + let restarted: Awaited<ReturnType<typeof spinUpServer>> | null = null; + try { + const approvalCase = await createAgentConnectRequest({ + asUrl: first.asUrl, + clientName: "Agent Connect PG Approval ID Deny", + }); + const approvalId = await postgresApprovalIdForRequestUri(approvalCase.staged.request_uri); + const approvalDeny = await fetch(`${first.asUrl}/consent/deny`, { + body: new URLSearchParams({ approval_id: approvalId }).toString(), + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(approvalDeny.status, 200); + const approvalPoll = await pollAgentConnectToken({ + pollingCode: approvalCase.start.polling_code, + tokenUrl: approvalCase.start.token_url, + }); + assert.equal(approvalPoll.resp.status, 403); + assert.equal(errorCode(approvalPoll.body), "access_denied"); + assert.doesNotMatch(JSON.stringify(approvalPoll.body), DENIED_POLL_SECRET_PATTERN); + + const recoveryCase = await createAgentConnectRequest({ + asUrl: first.asUrl, + clientName: "Agent Connect PG Denial Recovery", + }); + __setAgentConnectCompleteFailureForTest(() => { + throw new Error("denial completion seam crash"); + }); + try { + const failedDeny = await fetch(`${first.asUrl}/consent/deny`, { + body: new URLSearchParams({ request_uri: recoveryCase.staged.request_uri }).toString(), + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(failedDeny.status, 500); + } finally { + __setAgentConnectCompleteFailureForTest(null); + } + await closeServer(first.server); + await closePostgresStorage(); + restarted = await spinUpServer({ databaseUrl, storageBackend: "postgres" }); + const retryDeny = await fetch(`${restarted.asUrl}/consent/deny`, { + body: new URLSearchParams({ request_uri: recoveryCase.staged.request_uri }).toString(), + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(retryDeny.status, 404); + const recoveryPoll = await pollAgentConnectToken({ + pollingCode: recoveryCase.start.polling_code, + tokenUrl: rewriteUrlOrigin(recoveryCase.start.token_url, restarted.asUrl), + }); + assert.equal(recoveryPoll.resp.status, 403); + assert.equal(errorCode(recoveryPoll.body), "access_denied"); + assert.doesNotMatch(JSON.stringify(recoveryPoll.body), DENIED_POLL_SECRET_PATTERN); + } finally { + __setAgentConnectCompleteFailureForTest(null); + if (restarted) { + await closeServer(restarted.server); + } else { + await closeServer(first.server); + } + await closePostgresStorage(); + } + } + ); +}); + test("agent-connect: expired polling handle returns bounded expired_token", async () => { const { server, asUrl } = await spinUpServer({ agentConnectTtlMs: 1 }); try { @@ -656,6 +2034,121 @@ test("agent-connect: expired polling handle returns bounded expired_token", asyn } }); +test("agent-connect: approved attempt that expires before delivery revokes the stranded bearer", async () => { + const { server, asUrl, rsUrl } = await spinUpServer({ agentConnectTtlMs: 1 }); + try { + const { staged, start } = await createAgentConnectRequest({ asUrl, clientName: "Agent Connect Approved Expiry" }); + const approval = await approveInline({ asUrl, requestUri: staged.request_uri, subjectId: "owner_local" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const expiredPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(expiredPoll.resp.status, 400); + assert.equal(errorCode(expiredPoll.body), "expired_token"); + assert.doesNotMatch(JSON.stringify(expiredPoll.body), EXPIRED_POLL_SECRET_PATTERN); + + const schemaResp = await fetch(`${rsUrl}/v1/schema`, { + headers: { Authorization: `Bearer ${approval.token}` }, + }); + assert.ok( + schemaResp.status === 401 || schemaResp.status === 403, + "expired approved delivery must revoke the already-minted bearer" + ); + } finally { + await closeServer(server); + } +}); + +test("agent-connect: approved attempt fails closed when the grant is revoked before delivery", async () => { + const { server, asUrl } = await spinUpServer(); + try { + const { staged, start } = await createAgentConnectRequest({ asUrl, clientName: "Agent Connect Approved Revoke" }); + const approval = await approveInline({ asUrl, requestUri: staged.request_uri, subjectId: "owner_local" }); + assert.ok(approval.grantId, "approval should include grant_id"); + const revokeResp = await fetch(`${asUrl}/grants/${encodeURIComponent(approval.grantId)}/revoke`, { + headers: { Authorization: `Bearer ${approval.token}` }, + method: "POST", + }); + assert.ok(revokeResp.ok, "grant revoke should succeed before agent delivery"); + + const revokedPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(revokedPoll.resp.status, 401); + assert.equal(errorCode(revokedPoll.body), "invalid_grant"); + assert.doesNotMatch(JSON.stringify(revokedPoll.body), ACCESS_TOKEN_PATTERN); + } finally { + await closeServer(server); + } +}); + +test("agent-connect: live Postgres approved expiry and revocation fail closed before delivery", async (t) => { + const baseUrl = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); + if (!baseUrl) { + t.skip("PDPP_TEST_POSTGRES_URL must target the dedicated local Postgres test listener"); + return; + } + + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: baseUrl, + databaseName: "pdpp_test_agent_connect_expiry_revoke", + }, + async (databaseUrl) => { + const expiryServer = await spinUpServer({ agentConnectTtlMs: 1, databaseUrl, storageBackend: "postgres" }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl: expiryServer.asUrl, + clientName: "Agent Connect PG Approved Expiry", + }); + await approveInline({ asUrl: expiryServer.asUrl, requestUri: staged.request_uri, subjectId: "owner_local" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const expiredPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(expiredPoll.resp.status, 400); + assert.equal(errorCode(expiredPoll.body), "expired_token"); + } finally { + await closeServer(expiryServer.server); + await closePostgresStorage(); + } + + const revokeServer = await spinUpServer({ databaseUrl, storageBackend: "postgres" }); + try { + const { staged, start } = await createAgentConnectRequest({ + asUrl: revokeServer.asUrl, + clientName: "Agent Connect PG Approved Revoke", + }); + const approval = await approveInline({ + asUrl: revokeServer.asUrl, + requestUri: staged.request_uri, + subjectId: "owner_local", + }); + assert.ok(approval.grantId, "approval should include grant_id"); + const revokeResp = await fetch(`${revokeServer.asUrl}/grants/${encodeURIComponent(approval.grantId)}/revoke`, { + headers: { Authorization: `Bearer ${approval.token}` }, + method: "POST", + }); + assert.ok(revokeResp.ok, "grant revoke should succeed before agent delivery"); + const revokedPoll = await pollAgentConnectToken({ + pollingCode: start.polling_code, + tokenUrl: start.token_url, + }); + assert.equal(revokedPoll.resp.status, 401); + assert.equal(errorCode(revokedPoll.body), "invalid_grant"); + } finally { + await closeServer(revokeServer.server); + await closePostgresStorage(); + } + } + ); +}); + test("agent-connect: approved scoped token cannot access ungranted stream", async () => { const { server, asUrl, rsUrl } = await spinUpServer(); try { @@ -681,8 +2174,8 @@ test("agent-connect: approved scoped token cannot access ungranted stream", asyn headers: { Authorization: `Bearer ${completedPoll.body.access_token}` }, }); const body = await streamResp.json(); - assert.equal(streamResp.status, 403); - assert.match(errorCode(body) || JSON.stringify(body), FORBIDDEN_SCOPE_PATTERN); + assert.equal(streamResp.status, 401, JSON.stringify(body)); + assert.equal(errorCode(body), "context.stream_not_allowed"); } finally { await closeServer(server); } @@ -696,7 +2189,7 @@ test("agent-connect: schema verification fails cleanly for invalid bearer", asyn }); const body = await schemaResp.json(); assert.equal(schemaResp.status, 401); - assert.equal(errorCode(body), "authentication_error"); + assert.equal(errorCode(body), "context.active_false"); assert.doesNotMatch(JSON.stringify(body), INVALID_TOKEN_PATTERN); } finally { await closeServer(server); diff --git a/reference-implementation/test/aggregate-time-buckets.test.ts b/reference-implementation/test/aggregate-time-buckets.test.ts index 414db212b..fca39f862 100644 --- a/reference-implementation/test/aggregate-time-buckets.test.ts +++ b/reference-implementation/test/aggregate-time-buckets.test.ts @@ -65,6 +65,7 @@ function manifestWith(aggregations: AggregationsDeclaration) { capabilities: { human_interaction: [] }, connector_id: CONNECTOR_ID, display_name: "Aggregate Time Buckets Test Connector", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, protocol_version: "0.1.0", streams: [ { @@ -82,6 +83,8 @@ function manifestWith(aggregations: AggregationsDeclaration) { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", diff --git a/reference-implementation/test/approval-review-artifact-rendering.test.ts b/reference-implementation/test/approval-review-artifact-rendering.test.ts new file mode 100644 index 000000000..35ccd0556 --- /dev/null +++ b/reference-implementation/test/approval-review-artifact-rendering.test.ts @@ -0,0 +1,452 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { parsePendingConsentRequestUri, registerConnector, seedPreRegisteredClients } from "../server/auth.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; +import { closeDb, getDb } from "../server/db.ts"; +import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLIENT_ID = "approval_artifact_rendering_client"; +const INSTANCE_ID = "cin_approval_artifact_spotify"; +const SOURCE_ID = "https://sources.example.test/approval-artifact/spotify"; +const BATCH_DRIFT_RE = /REJECTED SOURCE PURPOSE|MUTABLE BATCH|MUTABLE CATALOG DRIFT/; +const BATCH_FINAL_FIELDS_RE = /name="approved_source_indexes"|name="narrow_streams_/; +const MUTABLE_BATCH_CLAIM_RE = /MUTABLE BATCH CLAIM/; +const CONFIRM_REVIEWED_DECISION_RE = /name="confirm_reviewed_decision"/; +const CLIENT_CLAIM_DISCLAIMER_RE = /not enforced by your server/i; +const CONCERT_RECOMMENDATIONS_RE = /Only use this for concert recommendations/; +const MUTABLE_CLAIM_DRIFT_RE = /MUTABLE CLAIM DRIFT/; +const REGEXP_SPECIAL_CHARACTERS_RE = /[.*+?^${}()|[\]\\]/g; +const SINGLE_DRIFT_RE = /MUTABLE REQUEST|MUTABLE CATALOG DRIFT|drift\.example\.test/; +const SINGLE_FROZEN_FIELDS_RE = /name="subject_id"|name="ai_training_consented"/; + +interface TestHttpServer { + close: (callback: () => void) => void; + closeAllConnections?: () => void; +} + +interface TestServerHandle { + abortStartupBackfill: (reason: string) => void; + asPort: number; + asServer: TestHttpServer; + rsServer: TestHttpServer; + schedulerManager?: { stop: () => void }; + startupBackfillDone: Promise<unknown>; + startupRunHistoryBackfillDone: Promise<unknown>; + startupSummaryEvidenceSweepDone: Promise<unknown>; + stopBrowserSurfaceLeaseSweep: () => void; + stopClientEventDeliveryWorker: () => Promise<void>; + stopConnectorMaintenanceSweep: () => void; +} + +interface ReviewStream { + fields: string[]; + instance_ids: string[]; + name: string; + resources?: string[]; + time_constraint?: { field: string; since?: string; until?: string }; +} + +interface ReviewSource { + access_mode: string; + client_claims?: { commitments?: string[] } | null; + index: number; + purpose_description: string | null; + resolved_streams: ReviewStream[]; + source: { id: string; kind: string }; + source_declaration: { digest: string; version: string }; +} + +interface ReviewArtifact { + access_mode: string | null; + approved_source_indexes?: number[]; + client: { client_id: string }; + client_claims?: { commitments?: string[] } | null; + purpose_description?: string | null; + resolved_streams?: ReviewStream[]; + source?: { id: string; kind: string }; + source_declaration?: { digest: string; version: string }; + source_narrowing?: Record<string, unknown>; + sources?: ReviewSource[]; + subject: { id: string }; +} + +async function closeServer(server: TestServerHandle): Promise<void> { + server.abortStartupBackfill("approval artifact rendering test shutdown"); + server.schedulerManager?.stop(); + server.stopBrowserSurfaceLeaseSweep(); + server.stopConnectorMaintenanceSweep(); + server.asServer.closeAllConnections?.(); + server.rsServer.closeAllConnections?.(); + const closeOne = (target: TestHttpServer) => + new Promise<void>((resolve) => { + const timer = setTimeout(resolve, 2000); + target.close(() => { + clearTimeout(timer); + resolve(); + }); + }); + await Promise.allSettled([ + closeOne(server.asServer), + closeOne(server.rsServer), + server.startupBackfillDone, + server.startupRunHistoryBackfillDone, + server.startupSummaryEvidenceSweepDone, + server.stopClientEventDeliveryWorker(), + ]); +} + +function loadSpotifyManifest(): Record<string, unknown> & { connector_id: string } { + return JSON.parse(readFileSync(join(__dirname, "../manifests/spotify.json"), "utf8")); +} + +function sourceManifest() { + const manifest = loadSpotifyManifest(); + const topArtists = (manifest.streams as Record<string, unknown>[]).find((stream) => stream.name === "top_artists"); + assert.ok(topArtists); + const { + coverage_strategy: _coverageStrategy, + freshness_strategy: _freshnessStrategy, + incremental: _incremental, + ...declarationStream + } = topArtists; + return { + ...manifest, + source_declaration: { + declaration_version: "approval-artifact-declaration-v1", + display: { name: "Approval artifact Spotify" }, + protocol_version: "0.1.0", + publisher: { id: "https://publishers.example.test/approval-artifact" }, + source: { id: SOURCE_ID, kind: "connector" }, + streams: [declarationStream], + }, + }; +} + +async function setup(): Promise<{ asUrl: string; server: TestServerHandle }> { + const server = (await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 })) as TestServerHandle; + const asUrl = `http://localhost:${server.asPort}`; + const manifest = loadSpotifyManifest(); + const registered = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(manifest), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(registered.status, 201, await registered.text()); + await registerConnector(sourceManifest()); + await seedPreRegisteredClients([ + { + client_id: CLIENT_ID, + client_name: "Frozen approval client", + client_uri: "https://client.example.test/frozen", + registration_mode: "pre_registered_public", + }, + ]); + const connectorId = canonicalConnectorKey(manifest.connector_id) ?? manifest.connector_id; + const now = new Date().toISOString(); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: INSTANCE_ID, + createdAt: now, + displayName: "Frozen approval account", + ownerSubjectId: "owner_local", + sourceBinding: { fixture: INSTANCE_ID }, + sourceBindingKey: INSTANCE_ID, + sourceKind: "account", + status: "active", + updatedAt: now, + }); + return { asUrl, server }; +} + +async function stage(asUrl: string, details: Record<string, unknown>[]): Promise<string> { + const response = await fetch(`${asUrl}/oauth/par`, { + body: JSON.stringify({ authorization_details: details, client_id: CLIENT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const text = await response.text(); + assert.equal(response.status, 201, text); + const body = JSON.parse(text) as { request_uri?: string }; + assert.ok(body.request_uri); + return body.request_uri; +} + +function selection(purposeDescription: string, claims?: { commitments: string[] }): Record<string, unknown> { + return { + access_mode: "continuous", + ...(claims ? { client_claims: claims } : {}), + purpose_code: "https://pdpp.dev/purpose/personalization", + purpose_description: purposeDescription, + retention: { max_duration: "P30D", on_expiry: "delete" }, + source: { id: SOURCE_ID, kind: "connector" }, + streams: [ + { + fields: ["id", "name", "genres"], + instance_ids: [INSTANCE_ID], + name: "top_artists", + resources: ["artist-frozen-42"], + time_range: { since: "2026-01-01T00:00:00Z", until: "2026-07-01T00:00:00Z" }, + }, + ], + type: "https://pdpp.dev/data-access", + }; +} + +async function finalizeJsonReview( + asUrl: string, + requestUri: string, + body: Record<string, unknown> = {} +): Promise<{ artifact: ReviewArtifact; revision: string }> { + const response = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ ...body, request_uri: requestUri, subject_id: "owner_local" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const text = await response.text(); + assert.equal(response.status, 200, text); + const parsed = JSON.parse(text) as { approval_review: ReviewArtifact; approval_review_revision: string }; + assert.equal(typeof parsed.approval_review_revision, "string"); + return { artifact: parsed.approval_review, revision: parsed.approval_review_revision }; +} + +async function approveJson(asUrl: string, requestUri: string, revision: string): Promise<Record<string, unknown>> { + const response = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ approval_review_revision: revision, request_uri: requestUri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const text = await response.text(); + assert.equal(response.status, 200, text); + return JSON.parse(text) as Record<string, unknown>; +} + +function grantRights(grant: Record<string, unknown>): Record<string, unknown> { + return { + access_mode: grant.access_mode, + client: grant.client, + purpose_code: grant.purpose_code, + purpose_description: grant.purpose_description, + retention: grant.retention, + source: grant.source, + source_declaration: grant.source_declaration, + streams: grant.streams, + subject: grant.subject, + version: grant.version, + }; +} + +function mutateStagedRequest(deviceCode: string, mutate: (request: Record<string, unknown>) => void): void { + const row = getDb() + .prepare("SELECT params_json FROM pending_consents WHERE device_code = ?") + .get<{ params_json: string }>(deviceCode); + assert.ok(row); + const request = JSON.parse(row.params_json) as Record<string, unknown>; + mutate(request); + getDb() + .prepare("UPDATE pending_consents SET params_json = ? WHERE device_code = ?") + .run(JSON.stringify(request), deviceCode); + getDb().prepare("UPDATE connectors SET manifest = replace(manifest, 'Spotify', 'MUTABLE CATALOG DRIFT')").run(); +} + +test.afterEach(() => { + closeDb(); +}); + +test("resumed single HTML renders the same validated artifact as JSON despite request and catalog drift", async () => { + const { asUrl, server } = await setup(); + try { + const requestUri = await stage(asUrl, [selection("FROZEN SINGLE PURPOSE")]); + const { artifact } = await finalizeJsonReview(asUrl, requestUri); + const deviceCode = parsePendingConsentRequestUri(requestUri); + assert.ok(deviceCode); + const [stream] = artifact.resolved_streams ?? []; + assert.ok(stream); + assert.ok(artifact.source); + assert.ok(artifact.source_declaration); + + mutateStagedRequest(deviceCode, (request) => { + request.client = { client_id: "MUTABLE REQUEST CLIENT", registration_mode: "pre_registered_public" }; + const requestSelection = request.selection as Record<string, unknown>; + requestSelection.purpose_description = "MUTABLE REQUEST PURPOSE"; + request.source_binding = { id: "https://drift.example.test/source", kind: "provider_native" }; + }); + + const resumed = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(requestUri)}`); + const html = await resumed.text(); + assert.equal(resumed.status, 200, html); + for (const fact of [ + artifact.client.client_id, + artifact.subject.id, + artifact.source.id, + artifact.source.kind, + artifact.source_declaration.version, + artifact.source_declaration.digest, + artifact.purpose_description, + stream.name, + ...stream.instance_ids, + ...stream.fields, + ...(stream.resources ?? []), + stream.time_constraint?.field, + stream.time_constraint?.since, + stream.time_constraint?.until, + ]) { + assert.match(html, new RegExp(String(fact).replace(REGEXP_SPECIAL_CHARACTERS_RE, "\\$&"))); + } + assert.doesNotMatch(html, SINGLE_DRIFT_RE); + assert.doesNotMatch(html, SINGLE_FROZEN_FIELDS_RE); + } finally { + await closeServer(server); + } +}); + +test("resumed batch HTML renders only approved frozen sources, order, and narrowing", async () => { + const { asUrl, server } = await setup(); + try { + const batchClaims = { commitments: ["Only use this approved source for batch recommendations"] }; + const requestUri = await stage(asUrl, [ + selection("REJECTED SOURCE PURPOSE"), + selection("FROZEN BATCH PURPOSE", batchClaims), + ]); + const { artifact } = await finalizeJsonReview(asUrl, requestUri, { + approved_source_indexes: [1], + source_narrowing: { + 1: { + fields: { top_artists: ["id", "name"] }, + since: { top_artists: "2026-02-01T00:00:00Z" }, + streams: ["top_artists"], + }, + }, + }); + assert.deepEqual(artifact.approved_source_indexes, [1]); + assert.equal(artifact.sources?.length, 1); + const source = artifact.sources?.[0]; + assert.ok(source); + assert.deepEqual(source.client_claims, batchClaims); + const [stream] = source.resolved_streams; + assert.ok(stream); + const deviceCode = parsePendingConsentRequestUri(requestUri); + assert.ok(deviceCode); + + mutateStagedRequest(deviceCode, (request) => { + request.client = { client_id: "MUTABLE BATCH CLIENT", registration_mode: "pre_registered_public" }; + const entries = request.entries as Record<string, unknown>[]; + (entries[1]?.selection as Record<string, unknown>).purpose_description = "MUTABLE BATCH PURPOSE"; + (entries[1]?.selection as Record<string, unknown>).client_claims = { commitments: ["MUTABLE BATCH CLAIM"] }; + }); + + const resumed = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(requestUri)}`); + const html = await resumed.text(); + assert.equal(resumed.status, 200, html); + for (const fact of [ + artifact.client.client_id, + artifact.subject.id, + source.purpose_description, + source.source.id, + source.source_declaration.version, + source.source_declaration.digest, + stream.name, + ...stream.instance_ids, + ...stream.fields, + ...(stream.resources ?? []), + "2026-02-01T00:00:00Z", + "Staged source index", + "Approval order", + batchClaims.commitments[0], + ]) { + assert.match(html, new RegExp(String(fact).replace(REGEXP_SPECIAL_CHARACTERS_RE, "\\$&"))); + } + assert.doesNotMatch(html, BATCH_DRIFT_RE); + assert.doesNotMatch(html, MUTABLE_BATCH_CLAIM_RE); + assert.doesNotMatch(html, BATCH_FINAL_FIELDS_RE); + assert.match(html, CONFIRM_REVIEWED_DECISION_RE); + assert.match(html, CLIENT_CLAIM_DISCLAIMER_RE); + } finally { + await closeServer(server); + } +}); + +test("resumed GET fails closed when the persisted artifact or digest is corrupt", async () => { + const { asUrl, server } = await setup(); + try { + const requestUri = await stage(asUrl, [selection("FROZEN CORRUPTION PURPOSE")]); + await finalizeJsonReview(asUrl, requestUri); + const deviceCode = parsePendingConsentRequestUri(requestUri); + assert.ok(deviceCode); + getDb() + .prepare("UPDATE pending_consents SET approval_review_digest = ? WHERE device_code = ?") + .run("sha256:corrupt", deviceCode); + const digestMismatch = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(requestUri)}`, { + headers: { Accept: "application/json" }, + }); + assert.equal(digestMismatch.status, 400, await digestMismatch.text()); + + getDb() + .prepare("UPDATE pending_consents SET approval_review_json = ? WHERE device_code = ?") + .run("{not-json", deviceCode); + const malformed = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(requestUri)}`, { + headers: { Accept: "application/json" }, + }); + assert.equal(malformed.status, 400, await malformed.text()); + } finally { + await closeServer(server); + } +}); + +test("client claims are frozen in final review evidence without becoming grant rights", async () => { + const { asUrl, server } = await setup(); + try { + const firstClaims = { commitments: ["Only use this for concert recommendations"] }; + const secondClaims = { commitments: ["Only use this for playlist cleanup"] }; + const firstRequestUri = await stage(asUrl, [selection("FROZEN CLAIM PURPOSE", firstClaims)]); + const secondRequestUri = await stage(asUrl, [selection("FROZEN CLAIM PURPOSE", secondClaims)]); + + const firstReview = await finalizeJsonReview(asUrl, firstRequestUri); + const secondReview = await finalizeJsonReview(asUrl, secondRequestUri); + assert.notEqual(firstReview.revision, secondReview.revision, "client_claims must affect the review digest"); + assert.deepEqual(firstReview.artifact.client_claims, firstClaims); + assert.deepEqual(secondReview.artifact.client_claims, secondClaims); + + const firstDeviceCode = parsePendingConsentRequestUri(firstRequestUri); + assert.ok(firstDeviceCode); + mutateStagedRequest(firstDeviceCode, (request) => { + const requestSelection = request.selection as Record<string, unknown>; + requestSelection.client_claims = { commitments: ["MUTABLE CLAIM DRIFT"] }; + }); + const resumed = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(firstRequestUri)}`); + const html = await resumed.text(); + assert.equal(resumed.status, 200, html); + assert.match(html, CONCERT_RECOMMENDATIONS_RE); + assert.match(html, CLIENT_CLAIM_DISCLAIMER_RE); + assert.doesNotMatch(html, MUTABLE_CLAIM_DRIFT_RE); + + const staleApprove = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ approval_review_revision: firstReview.revision, request_uri: firstRequestUri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(staleApprove.status, 400, await staleApprove.text()); + + const thirdRequestUri = await stage(asUrl, [selection("FROZEN CLAIM PURPOSE", firstClaims)]); + const thirdReview = await finalizeJsonReview(asUrl, thirdRequestUri); + const firstApproved = await approveJson(asUrl, thirdRequestUri, thirdReview.revision); + const secondApproved = await approveJson(asUrl, secondRequestUri, secondReview.revision); + const firstGrant = firstApproved.grant as Record<string, unknown>; + const secondGrant = secondApproved.grant as Record<string, unknown>; + assert.ok(firstGrant); + assert.ok(secondGrant); + assert.equal(firstGrant.client_claims, undefined); + assert.equal(secondGrant.client_claims, undefined); + assert.deepEqual(grantRights(firstGrant), grantRights(secondGrant)); + } finally { + await closeServer(server); + } +}); diff --git a/reference-implementation/test/approval-review-seam.test.ts b/reference-implementation/test/approval-review-seam.test.ts new file mode 100644 index 000000000..477cdef0f --- /dev/null +++ b/reference-implementation/test/approval-review-seam.test.ts @@ -0,0 +1,1186 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 +// biome-ignore-all lint/performance/useTopLevelRegex: This focused test keeps assertion regexes local to each oracle. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { validateResponse } from "@pdpp/reference-contract"; + +import { + approveGrant, + getPendingConsent, + initiateGrant, + parsePendingConsentRequestUri, + registerConnector, + seedPreRegisteredClients, +} from "../server/auth.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; +import { closeDb, getDb, initDb } from "../server/db.ts"; +import { startServer } from "../server/index.ts"; +import { closePostgresStorage, initPostgresStorage, postgresQuery } from "../server/postgres-storage.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; +import { dedicatedPostgresTestUrl } from "./helpers/dedicated-postgres-test-url.ts"; +import { withTemporaryPostgresDatabase } from "./helpers/postgres-temp-database.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REFERENCE_IMPL_DIR = join(__dirname, ".."); +const CLIENT_ID = "approval_review_fixture_client"; +const INSTANCE_ID = "cin_approval_review_spotify"; +const SOURCE_ID = "https://sources.example.test/approval-review/spotify"; +const POSTGRES_URL = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); + +type Backend = "sqlite" | "postgres"; + +interface PendingReviewView { + request?: { source_binding?: unknown }; +} + +interface TestHttpServer { + close: (callback: () => void) => void; + closeAllConnections?: () => void; +} + +interface TestServerHandle { + asPort: number; + asServer: TestHttpServer; + rsServer: TestHttpServer; +} + +async function closeServer(server: TestServerHandle): Promise<void> { + server.asServer.closeAllConnections?.(); + server.rsServer.closeAllConnections?.(); + const closeOne = (srv: TestHttpServer) => + new Promise<void>((resolve) => { + const timer = setTimeout(resolve, 2000); + srv.close(() => { + clearTimeout(timer); + resolve(); + }); + }); + await Promise.allSettled([closeOne(server.asServer), closeOne(server.rsServer)]); +} + +function loadSpotifyManifest(): Record<string, unknown> & { connector_id: string } { + return JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8")); +} + +async function setup(backend: Backend = "sqlite", databaseUrl = POSTGRES_URL) { + process.env.PDPP_TEST_CURRENT_BACKEND = backend; + if (backend === "postgres") { + if (!databaseUrl) { + throw new Error("PDPP_TEST_POSTGRES_URL is required for postgres approval seam setup"); + } + await initPostgresStorage({ backend: "postgres", databaseUrl }); + } else { + initDb(":memory:"); + } + const manifest = loadSpotifyManifest(); + await registerConnector(manifest); + await seedPreRegisteredClients([ + { + client_id: CLIENT_ID, + client_name: "Approval Review Fixture", + registration_mode: "pre_registered_public", + }, + ]); + const now = new Date().toISOString(); + const connectorId = canonicalConnectorKey(manifest.connector_id) ?? manifest.connector_id; + await createRequestConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: INSTANCE_ID, + createdAt: now, + displayName: "Approval review Spotify", + ownerSubjectId: "owner_local", + sourceBinding: { fixture: INSTANCE_ID }, + sourceBindingKey: INSTANCE_ID, + sourceKind: "account", + status: "active", + updatedAt: now, + }); + return manifest; +} + +async function countRows(tableName: string): Promise<number> { + if (process.env.PDPP_TEST_CURRENT_BACKEND === "postgres") { + const result = await postgresQuery<{ n: string }>(`SELECT COUNT(*) AS n FROM ${tableName}`); + return Number(result.rows[0]?.n ?? 0); + } + return (getDb().prepare(`SELECT COUNT(*) AS n FROM ${tableName}`).get() as { n: number }).n; +} + +async function pendingStatus(deviceCode: string): Promise<{ review: string | null; status: string } | null> { + if (process.env.PDPP_TEST_CURRENT_BACKEND === "postgres") { + const result = await postgresQuery<{ approval_review_revision: string | null; status: string }>( + "SELECT status, approval_review_revision FROM pending_consents WHERE device_code = $1", + [deviceCode] + ); + const [row] = result.rows; + return row ? { review: row.approval_review_revision, status: row.status } : null; + } + const row = getDb() + .prepare("SELECT status, approval_review_revision FROM pending_consents WHERE device_code = ?") + .get<{ approval_review_revision: string | null; status: string }>(deviceCode); + return row ? { review: row.approval_review_revision, status: row.status } : null; +} + +function nativeManifest() { + const manifest = loadSpotifyManifest(); + return { + ...manifest, + source_declaration: { + declaration_version: "approval-review-test-v1", + display: { name: "Spotify" }, + protocol_version: "0.1.0", + publisher: { id: "https://publishers.example.test/reference" }, + source: { id: SOURCE_ID, kind: "connector" }, + streams: [ + { + name: "top_artists", + primary_key: ["id"], + schema: { properties: { id: { type: "string" }, name: { type: "string" } }, type: "object" }, + selection: { fields: true, resources: false }, + semantics: "mutable_state", + views: [{ fields: ["id", "name"], id: "basic", label: "Basic" }], + }, + ], + }, + storage_binding: { connector_id: canonicalConnectorKey(manifest.connector_id) ?? manifest.connector_id }, + }; +} + +async function stage(stream: Record<string, unknown> = { name: "top_artists", view: "basic" }) { + const initiated = await initiateGrant( + { + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + purpose_description: "approval review seam", + source: { id: SOURCE_ID, kind: "connector" }, + streams: [stream], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }, + { nativeManifest: nativeManifest() } + ); + const deviceCode = parsePendingConsentRequestUri(initiated.request_uri); + assert.ok(deviceCode); + return deviceCode; +} + +async function stageBatch(accessMode: "continuous" | "single_use" = "continuous"): Promise<string> { + const initiated = await initiateGrant( + { + authorization_details: [ + { + access_mode: accessMode, + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: SOURCE_ID, kind: "connector" }, + streams: [{ instance_ids: [INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + { + access_mode: accessMode, + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: SOURCE_ID, kind: "connector" }, + streams: [{ instance_ids: [INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }, + { nativeManifest: nativeManifest() } + ); + const deviceCode = parsePendingConsentRequestUri(initiated.request_uri); + assert.ok(deviceCode); + return deviceCode; +} + +async function issuedGrantRows(): Promise<{ consumed: number; expires_at: string | null }[]> { + if (process.env.PDPP_TEST_CURRENT_BACKEND === "postgres") { + const result = await postgresQuery<{ consumed: boolean; expires_at: string | null }>( + "SELECT consumed, expires_at FROM grants ORDER BY grant_id" + ); + return result.rows.map((row) => ({ consumed: row.consumed ? 1 : 0, expires_at: row.expires_at })); + } + return getDb().prepare("SELECT consumed, expires_at FROM grants ORDER BY grant_id").all() as { + consumed: number; + expires_at: string | null; + }[]; +} + +async function stageHttpBatch(asUrl: string, sourceId: string): Promise<string> { + const resp = await fetch(`${asUrl}/oauth/par`, { + body: JSON.stringify({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: sourceId, kind: "connector" }, + streams: [{ instance_ids: [INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: sourceId, kind: "connector" }, + streams: [{ instance_ids: [INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const text = await resp.text(); + assert.equal(resp.status, 201, text); + const body = JSON.parse(text) as { request_uri?: string }; + assert.ok(body.request_uri); + return body.request_uri; +} + +async function stageHttpSingle(asUrl: string, sourceId: string): Promise<string> { + const resp = await fetch(`${asUrl}/oauth/par`, { + body: JSON.stringify({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: sourceId, kind: "connector" }, + streams: [{ instance_ids: [INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const text = await resp.text(); + assert.equal(resp.status, 201, text); + const body = JSON.parse(text) as { request_uri?: string }; + assert.ok(body.request_uri); + return body.request_uri; +} + +function extractReviewRevision(html: string): string { + const match = /name="approval_review_revision" value="([^"]+)"/.exec(html); + assert.ok(match?.[1], "rendered final review must include approval_review_revision"); + return match[1]; +} + +test.afterEach(async () => { + delete process.env.PDPP_TEST_CURRENT_BACKEND; + closeDb(); + await closePostgresStorage(); +}); + +test("staged batch approval requires and binds finalized batch review revision", async () => { + await setup(); + const deviceCode = await stageBatch(); + const initial = await getPendingConsent(deviceCode, { subjectId: "owner_local" }); + assert.equal(initial?.reviewRevision, null, "initial batch GET must not persist an approve-all review"); + await assert.rejects( + () => approveGrant(deviceCode, "owner_local", { approvedSourceIndexes: [0, 1] }), + /approval_review_revision is required/ + ); + const review = await getPendingConsent(deviceCode, { + approvedSourceIndexes: [0, 1], + finalizeReview: true, + subjectId: "owner_local", + }); + assert.equal(typeof review?.reviewRevision, "string"); + assert.match(String(review?.reviewRevision), /^reference\.batch-approval-review\.v1:/); + const artifact = getDb() + .prepare("SELECT approval_review_json FROM pending_consents WHERE device_code = ?") + .get<{ approval_review_json: string }>(deviceCode); + assert.ok(artifact?.approval_review_json); + assert.match(artifact.approval_review_json, /approved_source_indexes/); + assert.match(artifact.approval_review_json, /resolved_streams/); + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + approvedSourceIndexes: [0, 1], + }); + assert.equal(approved.grant.package, true); + assert.equal((getDb().prepare("SELECT COUNT(*) AS n FROM grant_packages").get() as { n: number }).n, 1); + assert.equal((getDb().prepare("SELECT COUNT(*) AS n FROM grant_package_members").get() as { n: number }).n, 2); +}); + +test("single-use SQLite atomic approval marks the grant consumed", async () => { + await setup(); + const initiated = await initiateGrant( + { + authorization_details: [ + { + access_mode: "single_use", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: SOURCE_ID, kind: "connector" }, + streams: [{ instance_ids: [INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }, + { nativeManifest: nativeManifest() } + ); + const deviceCode = parsePendingConsentRequestUri(initiated.request_uri); + assert.ok(deviceCode); + const review = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + }); + const row = getDb() + .prepare("SELECT consumed FROM grants WHERE grant_id = ?") + .get<{ consumed: number }>(approved.grant.grant_id as string); + assert.equal(row?.consumed, 1); +}); + +test("batch final review binds changed source indexes before issuing", async () => { + await setup(); + const deviceCode = await stageBatch(); + await assert.rejects( + () => approveGrant(deviceCode, "owner_local", { approvedSourceIndexes: [1] }), + /approval_review_revision is required/ + ); + assert.equal(await countRows("grant_packages"), 0); + assert.equal(await countRows("grants"), 0); + + const review = await getPendingConsent(deviceCode, { + approvedSourceIndexes: [1], + finalizeReview: true, + subjectId: "owner_local", + }); + assert.equal(typeof review?.reviewRevision, "string"); + const artifact = getDb() + .prepare("SELECT approval_review_json FROM pending_consents WHERE device_code = ?") + .get<{ approval_review_json: string }>(deviceCode); + assert.ok(artifact?.approval_review_json); + assert.match(artifact.approval_review_json, /"approved_source_indexes":\[1\]/); + + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + approvedSourceIndexes: [1], + }); + assert.equal(approved.grant.package, true); + assert.equal(await countRows("grant_package_members"), 1); +}); + +test("single-use batch approval preserves reviewed expiry and consumes child grants", async () => { + await setup(); + const deviceCode = await stageBatch("single_use"); + const review = await getPendingConsent(deviceCode, { + approvedSourceIndexes: [0, 1], + finalizeReview: true, + subjectId: "owner_local", + }); + const artifact = getDb() + .prepare("SELECT approval_review_json FROM pending_consents WHERE device_code = ?") + .get<{ approval_review_json: string }>(deviceCode); + assert.ok(artifact?.approval_review_json); + const reviewed = JSON.parse(artifact.approval_review_json) as { expires_at: string }; + assert.equal(typeof reviewed.expires_at, "string"); + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + }); + assert.equal(approved.grant.package, true); + const grants = await issuedGrantRows(); + assert.equal(grants.length, 2); + assert.deepEqual( + grants.map((row) => row.expires_at), + [reviewed.expires_at, reviewed.expires_at] + ); + assert.deepEqual( + grants.map((row) => row.consumed), + [1, 1] + ); +}); + +test("approval requires the persisted reviewed revision", async () => { + await setup(); + const deviceCode = await stage({ instance_ids: [INSTANCE_ID], name: "top_artists", view: "basic" }); + + await assert.rejects(() => approveGrant(deviceCode, "owner_local"), /approval_review_revision is required/); + + const review = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + assert.equal(typeof review?.reviewRevision, "string"); + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + }); + assert.ok(approved.grant.grant_id); +}); + +test("malformed persisted single review rejects as invalid_request without issuing", async () => { + await setup(); + const deviceCode = await stage({ instance_ids: [INSTANCE_ID], name: "top_artists", view: "basic" }); + const review = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + getDb() + .prepare("UPDATE pending_consents SET approval_review_json = ? WHERE device_code = ?") + .run("{not-json", deviceCode); + await assert.rejects( + () => + approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + }), + /malformed|review/i + ); + assert.equal(await countRows("grants"), 0); + assert.equal(await countRows("tokens"), 0); +}); + +test("malformed persisted batch review rejects as invalid_request without issuing", async () => { + await setup(); + const deviceCode = await stageBatch(); + const review = await getPendingConsent(deviceCode, { + approvedSourceIndexes: [0, 1], + finalizeReview: true, + subjectId: "owner_local", + }); + getDb() + .prepare("UPDATE pending_consents SET approval_review_json = ? WHERE device_code = ?") + .run(JSON.stringify({ version: "reference.batch-approval-review.v1" }), deviceCode); + await assert.rejects( + () => + approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + }), + /malformed|review/i + ); + assert.equal(await countRows("grant_packages"), 0); + assert.equal(await countRows("grants"), 0); + assert.equal(await countRows("tokens"), 0); +}); + +test("review materializes exact omitted instance IDs and binds them at approval", async () => { + await setup(); + const deviceCode = await stage({ name: "top_artists", view: "basic" }); + const review = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + const artifact = getDb() + .prepare("SELECT approval_review_json FROM pending_consents WHERE device_code = ?") + .get<{ approval_review_json: string }>(deviceCode); + assert.ok(artifact?.approval_review_json); + assert.match(artifact.approval_review_json, new RegExp(INSTANCE_ID)); + + createSqliteConnectorInstanceStore().updateStatus(INSTANCE_ID, { + revokedAt: new Date().toISOString(), + status: "revoked", + updatedAt: new Date().toISOString(), + }); + await assert.rejects( + () => + approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + }), + /stale|eligible|review/i + ); +}); + +test("transaction-time instance revocation after review rejects without partial batch rows", async () => { + await setup(); + const deviceCode = await stageBatch(); + const review = await getPendingConsent(deviceCode, { + approvedSourceIndexes: [0, 1], + finalizeReview: true, + subjectId: "owner_local", + }); + createSqliteConnectorInstanceStore().updateStatus(INSTANCE_ID, { + revokedAt: new Date().toISOString(), + status: "revoked", + updatedAt: new Date().toISOString(), + }); + await assert.rejects( + () => + approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + approvedSourceIndexes: [0, 1], + }), + /no longer eligible|review/i + ); + assert.deepEqual(await pendingStatus(deviceCode), { review: review?.reviewRevision ?? null, status: "pending" }); + assert.equal(await countRows("grant_packages"), 0); + assert.equal(await countRows("grants"), 0); + assert.equal(await countRows("tokens"), 0); + assert.equal(await countRows("grant_package_members"), 0); + assert.equal(await countRows("spine_events"), 2, "request.submitted plus typed rejection event remain"); +}); + +test("transaction-time parent package revoke rejects incremental package without partial rows", async () => { + await setup(); + const rootDeviceCode = await stageBatch(); + const rootReview = await getPendingConsent(rootDeviceCode, { + approvedSourceIndexes: [0, 1], + finalizeReview: true, + subjectId: "owner_local", + }); + const root = await approveGrant(rootDeviceCode, "owner_local", { + approval_review_revision: rootReview?.reviewRevision, + approvedSourceIndexes: [0, 1], + }); + const rootPackageId = root.grant.package_id as string; + + const initiated = await initiateGrant( + { + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: SOURCE_ID, kind: "connector" }, + streams: [{ instance_ids: [INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + parent_package_id: rootPackageId, + }, + { nativeManifest: nativeManifest() } + ); + const deviceCode = parsePendingConsentRequestUri(initiated.request_uri); + assert.ok(deviceCode); + const review = await getPendingConsent(deviceCode, { + approvedSourceIndexes: [0], + finalizeReview: true, + subjectId: "owner_local", + }); + getDb() + .prepare("UPDATE grant_packages SET status = 'revoked', revoked_at = ? WHERE package_id = ?") + .run(new Date().toISOString(), rootPackageId); + const packageCountBefore = await countRows("grant_packages"); + const grantCountBefore = await countRows("grants"); + await assert.rejects( + () => + approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + approvedSourceIndexes: [0], + }), + /parent_package_id .*inactive|no longer eligible|review/i + ); + assert.deepEqual(await pendingStatus(deviceCode), { review: review?.reviewRevision ?? null, status: "pending" }); + assert.equal(await countRows("grant_packages"), packageCountBefore); + assert.equal(await countRows("grants"), grantCountBefore); +}); + +test("SQLite injected package trigger failure rolls back approval transaction", async () => { + await setup(); + const deviceCode = await stageBatch(); + const review = await getPendingConsent(deviceCode, { + approvedSourceIndexes: [0, 1], + finalizeReview: true, + subjectId: "owner_local", + }); + getDb().exec(` + CREATE TRIGGER approval_review_fault_after_package + AFTER INSERT ON grant_packages + BEGIN + SELECT RAISE(ABORT, 'injected package fault'); + END; + `); + await assert.rejects( + () => + approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + approvedSourceIndexes: [0, 1], + }), + /injected package fault/ + ); + assert.deepEqual(await pendingStatus(deviceCode), { review: review?.reviewRevision ?? null, status: "pending" }); + assert.equal(await countRows("grant_packages"), 0); + assert.equal(await countRows("grants"), 0); + assert.equal(await countRows("tokens"), 0); + assert.equal(await countRows("grant_package_members"), 0); + assert.equal(await countRows("spine_events"), 1); +}); + +test("HTTP batch final review resumes the exact result without duplicate issuance", async () => { + const manifest = loadSpotifyManifest(); + const server = (await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 })) as TestServerHandle; + const asUrl = `http://localhost:${server.asPort}`; + try { + const register = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(manifest), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(register.status, 201, await register.text()); + await seedPreRegisteredClients([ + { + client_id: CLIENT_ID, + client_name: "Approval Review Fixture", + registration_mode: "pre_registered_public", + }, + ]); + const connectorId = canonicalConnectorKey(manifest.connector_id) ?? manifest.connector_id; + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: INSTANCE_ID, + createdAt: new Date().toISOString(), + displayName: "Approval review Spotify", + ownerSubjectId: "owner_local", + sourceBinding: { fixture: INSTANCE_ID }, + sourceBindingKey: INSTANCE_ID, + sourceKind: "account", + status: "active", + updatedAt: new Date().toISOString(), + }); + + await registerConnector({ ...manifest, source_declaration: nativeManifest().source_declaration }); + const requestUri = await stageHttpBatch(asUrl, SOURCE_ID); + const deviceCode = parsePendingConsentRequestUri(requestUri); + assert.ok(deviceCode); + const approvalsResponse = await fetch(`${asUrl}/_ref/approvals`); + const approvalsResponseText = await approvalsResponse.text(); + assert.equal(approvalsResponse.status, 200, approvalsResponseText); + const approvals = JSON.parse(approvalsResponseText) as { + data?: Array<{ approval_id?: string; batch?: boolean; kind?: string; request_uri?: unknown }>; + }; + const approval = approvals.data?.find((entry) => entry.kind === "consent" && entry.batch === true); + assert.ok(approval?.approval_id, "batch must expose an opaque approval_id for hosted review"); + assert.equal(approval?.request_uri, null, "queue projection must keep request_uri scrubbed"); + const hostedByApprovalId = await fetch( + `${asUrl}/consent?approval_id=${encodeURIComponent(approval?.approval_id ?? "")}` + ); + const hostedByApprovalIdHtml = await hostedByApprovalId.text(); + assert.equal(hostedByApprovalId.status, 200, hostedByApprovalIdHtml); + assert.match(hostedByApprovalIdHtml, /Confirm each source/); + assert.ok( + !hostedByApprovalId.url.includes(deviceCode), + "hosted approval-id link must not put the device-code-equivalent request URI in the browser URL" + ); + const initial = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(requestUri)}`); + assert.equal(initial.status, 200); + const initialHtml = await initial.text(); + assert.doesNotMatch(initialHtml, /name="approval_review_revision"/); + + const firstJsonReview = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ approved_source_indexes: [1], request_uri: requestUri, subject_id: "owner_local" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const firstJsonReviewText = await firstJsonReview.text(); + assert.equal(firstJsonReview.status, 200, firstJsonReviewText); + const firstJsonReviewBody = JSON.parse(firstJsonReviewText) as { + approval_review?: { approved_source_indexes?: number[]; sources?: Array<{ resolved_streams?: unknown[] }> }; + approval_review_revision?: string; + }; + assert.deepEqual(firstJsonReviewBody.approval_review?.approved_source_indexes, [1]); + assert.ok(firstJsonReviewBody.approval_review?.sources?.[0]?.resolved_streams); + assert.equal(await countRows("grant_packages"), 0, "first JSON review POST must not issue"); + + const finalReview = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ approved_source_indexes: [1], request_uri: requestUri, subject_id: "owner_local" }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const finalReviewHtml = await finalReview.text(); + assert.equal(finalReview.status, 200, finalReviewHtml); + const reviewRevision = extractReviewRevision(finalReviewHtml); + assert.equal(firstJsonReviewBody.approval_review_revision, reviewRevision); + assert.match(finalReviewHtml, /name="confirm_reviewed_decision"/); + assert.doesNotMatch(finalReviewHtml, /name="approved_source_indexes"/); + assert.doesNotMatch(finalReviewHtml, /name="confirm_approve_all"/); + assert.doesNotMatch(finalReviewHtml, /name="narrow_streams_/); + assert.equal(await countRows("grant_packages"), 0, "final-review POST must not issue"); + + const resumedReview = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(requestUri)}`); + const resumedReviewHtml = await resumedReview.text(); + assert.equal(resumedReview.status, 200, resumedReviewHtml); + assert.match(resumedReviewHtml, /name="approval_review_revision"/); + assert.match(resumedReviewHtml, /name="confirm_reviewed_decision"/); + assert.doesNotMatch(resumedReviewHtml, /name="approved_source_indexes"/); + assert.doesNotMatch(resumedReviewHtml, /Confirm each source/); + assert.doesNotMatch(resumedReviewHtml, /aria-label="Source 1"/); + assert.match(resumedReviewHtml, /aria-label="Reviewed source 2"/); + + const jsonReview = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ approved_source_indexes: [1], request_uri: requestUri, subject_id: "owner_local" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const jsonReviewText = await jsonReview.text(); + assert.equal(jsonReview.status, 200, jsonReviewText); + const jsonReviewBody = JSON.parse(jsonReviewText) as { + approval_review?: { approved_source_indexes?: number[]; sources?: Array<{ resolved_streams?: unknown[] }> }; + approval_review_revision?: string; + }; + assert.deepEqual(jsonReviewBody.approval_review?.approved_source_indexes, [1]); + assert.ok(jsonReviewBody.approval_review?.sources?.[0]?.resolved_streams); + assert.equal(jsonReviewBody.approval_review_revision, reviewRevision); + + const badNarrowing = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ + approved_source_indexes: [1], + request_uri: requestUri, + source_narrowing: { nope: { streams: ["top_artists"] } }, + subject_id: "owner_local", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(badNarrowing.status, 400, await badNarrowing.text()); + + const nonCanonicalNarrowingKey = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ + approved_source_indexes: [1], + request_uri: requestUri, + source_narrowing: { "01": { streams: ["top_artists"] } }, + subject_id: "owner_local", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(nonCanonicalNarrowingKey.status, 400, await nonCanonicalNarrowingKey.text()); + + const forged = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: reviewRevision, + approved_source_indexes: [0, 1], + confirm_reviewed_decision: "1", + request_uri: requestUri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(forged.status, 400, await forged.text()); + assert.equal(await countRows("grant_packages"), 0, "forged second-step choices must not issue"); + + const subjectReplay = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: reviewRevision, + confirm_reviewed_decision: "1", + request_uri: requestUri, + subject_id: "owner_local", + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(subjectReplay.status, 400, await subjectReplay.text()); + assert.equal(await countRows("grant_packages"), 0, "final subject replay must not issue"); + + const approveBody = JSON.stringify({ + approval_review_revision: reviewRevision, + confirm_reviewed_decision: "1", + request_uri: requestUri, + }); + const countsBeforeApproval = { + events: await countRows("spine_events"), + grants: await countRows("grants"), + members: await countRows("grant_package_members"), + packages: await countRows("grant_packages"), + tokens: await countRows("tokens"), + }; + const [first, second] = await Promise.all([ + fetch(`${asUrl}/consent/approve`, { + body: approveBody, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }), + fetch(`${asUrl}/consent/approve`, { + body: approveBody, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }), + ]); + assert.deepEqual([first.status, second.status], [200, 200]); + const firstBody = (await first.json()) as { + grant?: { grant_id?: unknown }; + package_id?: unknown; + token?: unknown; + }; + const secondBody = (await second.json()) as typeof firstBody; + for (const body of [firstBody, secondBody]) { + const validation = validateResponse("approveConsent", { body, status: 200 }); + assert.equal(validation.ok, true, JSON.stringify(validation)); + } + assert.deepEqual(secondBody, firstBody, "approval retry must return the persisted package and token result"); + assert.equal(firstBody.grant?.grant_id, firstBody.package_id); + assert.equal(typeof firstBody.package_id, "string"); + assert.equal(typeof firstBody.token, "string"); + assert.equal(await countRows("grant_packages"), countsBeforeApproval.packages + 1); + assert.equal(await countRows("grants"), countsBeforeApproval.grants + 1); + assert.equal(await countRows("grant_package_members"), countsBeforeApproval.members + 1); + assert.equal(await countRows("tokens"), countsBeforeApproval.tokens + 2); + assert.equal(await countRows("spine_events"), countsBeforeApproval.events + 4); + } finally { + await closeServer(server); + } +}); + +test("HTTP single consent must be reviewed before approve", async () => { + const manifest = loadSpotifyManifest(); + const server = (await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 })) as TestServerHandle; + const asUrl = `http://localhost:${server.asPort}`; + try { + const register = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(manifest), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(register.status, 201, await register.text()); + await seedPreRegisteredClients([ + { + client_id: CLIENT_ID, + metadata: { + client_uri: "https://clients.example.test/approval-review", + logo_uri: "https://clients.example.test/approval-review/logo.svg", + token_endpoint_auth_method: "none", + }, + registration_mode: "pre_registered_public", + }, + ]); + const connectorId = canonicalConnectorKey(manifest.connector_id) ?? manifest.connector_id; + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: INSTANCE_ID, + createdAt: new Date().toISOString(), + displayName: "Approval review Spotify", + ownerSubjectId: "owner_local", + sourceBinding: { fixture: INSTANCE_ID }, + sourceBindingKey: INSTANCE_ID, + sourceKind: "account", + status: "active", + updatedAt: new Date().toISOString(), + }); + + await registerConnector({ ...manifest, source_declaration: nativeManifest().source_declaration }); + const requestUri = await stageHttpSingle(asUrl, SOURCE_ID); + const oneStep = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: "owner_local" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(oneStep.status, 400, await oneStep.text()); + assert.equal(await countRows("grants"), 0); + + const jsonReview = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: "owner_local" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const jsonReviewText = await jsonReview.text(); + assert.equal(jsonReview.status, 200, jsonReviewText); + const jsonReviewBody = JSON.parse(jsonReviewText) as { + approval_review?: { + client?: { client_display?: { logo_uri?: string | null; name?: string | null; uri?: string | null } }; + resolved_streams?: unknown[]; + version?: string; + }; + approval_review_revision?: string; + batch?: boolean; + }; + assert.equal(jsonReviewBody.batch, false); + assert.equal(jsonReviewBody.approval_review?.version, "reference.approval-review.v1"); + assert.ok(jsonReviewBody.approval_review?.resolved_streams); + assert.deepEqual(jsonReviewBody.approval_review?.client?.client_display, { + logo_uri: "https://clients.example.test/approval-review/logo.svg", + name: null, + policy_uri: null, + tos_uri: null, + uri: "https://clients.example.test/approval-review", + }); + + const review = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(requestUri)}`); + const reviewHtml = await review.text(); + assert.equal(review.status, 200, reviewHtml); + const reviewRevision = extractReviewRevision(reviewHtml); + assert.equal(jsonReviewBody.approval_review_revision, reviewRevision); + const approved = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: reviewRevision, + request_uri: requestUri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const approvedText = await approved.text(); + assert.equal(approved.status, 200, approvedText); + const approvedBody = JSON.parse(approvedText) as { grant?: { subject?: { id?: string } } }; + assert.equal(approvedBody.grant?.subject?.id, "owner_local"); + assert.equal(await countRows("grants"), 1); + } finally { + await closeServer(server); + } +}); + +test("HTTP single final approval derives custom subject from persisted review", async () => { + const manifest = loadSpotifyManifest(); + const customSubjectId = "owner_custom_review_subject"; + const server = (await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 })) as TestServerHandle; + const asUrl = `http://localhost:${server.asPort}`; + try { + const register = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(manifest), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(register.status, 201, await register.text()); + await seedPreRegisteredClients([ + { + client_id: CLIENT_ID, + client_name: "Approval Review Fixture", + registration_mode: "pre_registered_public", + }, + ]); + const connectorId = canonicalConnectorKey(manifest.connector_id) ?? manifest.connector_id; + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: INSTANCE_ID, + createdAt: new Date().toISOString(), + displayName: "Approval review Spotify", + ownerSubjectId: customSubjectId, + sourceBinding: { fixture: INSTANCE_ID }, + sourceBindingKey: INSTANCE_ID, + sourceKind: "account", + status: "active", + updatedAt: new Date().toISOString(), + }); + + await registerConnector({ ...manifest, source_declaration: nativeManifest().source_declaration }); + const requestUri = await stageHttpSingle(asUrl, SOURCE_ID); + const jsonReview = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: customSubjectId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const jsonReviewText = await jsonReview.text(); + assert.equal(jsonReview.status, 200, jsonReviewText); + const jsonReviewBody = JSON.parse(jsonReviewText) as { + approval_review?: { subject?: { id?: string } }; + approval_review_revision?: string; + }; + assert.equal(jsonReviewBody.approval_review?.subject?.id, customSubjectId); + assert.equal(typeof jsonReviewBody.approval_review_revision, "string"); + + const approved = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: jsonReviewBody.approval_review_revision, + request_uri: requestUri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const approvedText = await approved.text(); + assert.equal(approved.status, 200, approvedText); + const approvedBody = JSON.parse(approvedText) as { grant?: { subject?: { id?: string } } }; + assert.equal(approvedBody.grant?.subject?.id, customSubjectId); + } finally { + await closeServer(server); + } +}); + +test("PostgreSQL batch review/approval and transaction-time instance stale rejection", { + skip: POSTGRES_URL ? false : "PDPP_TEST_POSTGRES_URL unset", +}, async () => { + assert.ok(POSTGRES_URL); + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: POSTGRES_URL, + databaseName: "pdpp_test_pr114_batch_approval", + }, + async (databaseUrl) => { + await setup("postgres", databaseUrl); + const deviceCode = await stageBatch(); + const review = await getPendingConsent(deviceCode, { + approvedSourceIndexes: [0, 1], + finalizeReview: true, + subjectId: "owner_local", + }); + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + approvedSourceIndexes: [0, 1], + }); + assert.equal(approved.grant.package, true); + assert.equal(await countRows("grant_packages"), 1); + assert.equal(await countRows("grant_package_members"), 2); + + const staleDeviceCode = await stageBatch(); + const staleReview = await getPendingConsent(staleDeviceCode, { + approvedSourceIndexes: [0, 1], + finalizeReview: true, + subjectId: "owner_local", + }); + await createRequestConnectorInstanceStore().updateStatus(INSTANCE_ID, { + revokedAt: new Date().toISOString(), + status: "revoked", + updatedAt: new Date().toISOString(), + }); + const packageCountBefore = await countRows("grant_packages"); + await assert.rejects( + () => + approveGrant(staleDeviceCode, "owner_local", { + approval_review_revision: staleReview?.reviewRevision, + approvedSourceIndexes: [0, 1], + }), + /no longer eligible|review/i + ); + assert.deepEqual(await pendingStatus(staleDeviceCode), { + review: staleReview?.reviewRevision ?? null, + status: "pending", + }); + assert.equal(await countRows("grant_packages"), packageCountBefore); + } + ); +}); + +test("PostgreSQL injected package trigger failure rolls back approval transaction", { + skip: POSTGRES_URL ? false : "PDPP_TEST_POSTGRES_URL unset", +}, async () => { + assert.ok(POSTGRES_URL); + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: POSTGRES_URL, + databaseName: "pdpp_test_pr114_batch_rollback", + }, + async (databaseUrl) => { + await setup("postgres", databaseUrl); + const deviceCode = await stageBatch(); + const review = await getPendingConsent(deviceCode, { + approvedSourceIndexes: [0, 1], + finalizeReview: true, + subjectId: "owner_local", + }); + await postgresQuery(` + CREATE OR REPLACE FUNCTION approval_review_fault_after_package() + RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'injected package fault'; + END; + $$ LANGUAGE plpgsql; + `); + await postgresQuery(` + CREATE TRIGGER approval_review_fault_after_package + AFTER INSERT ON grant_packages + FOR EACH ROW EXECUTE FUNCTION approval_review_fault_after_package(); + `); + await assert.rejects( + () => + approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + approvedSourceIndexes: [0, 1], + }), + /injected package fault/ + ); + assert.deepEqual(await pendingStatus(deviceCode), { + review: review?.reviewRevision ?? null, + status: "pending", + }); + assert.equal(await countRows("grant_packages"), 0); + assert.equal(await countRows("grants"), 0); + assert.equal(await countRows("tokens"), 0); + assert.equal(await countRows("grant_package_members"), 0); + assert.equal(await countRows("spine_events"), 1); + } + ); +}); + +test("PostgreSQL single-use batch approval preserves reviewed expiry and consumes child grants", { + skip: POSTGRES_URL ? false : "PDPP_TEST_POSTGRES_URL unset", +}, async () => { + assert.ok(POSTGRES_URL); + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: POSTGRES_URL, + databaseName: "pdpp_test_pr114_batch_single_use", + }, + async (databaseUrl) => { + await setup("postgres", databaseUrl); + const deviceCode = await stageBatch("single_use"); + const review = await getPendingConsent(deviceCode, { + approvedSourceIndexes: [0, 1], + finalizeReview: true, + subjectId: "owner_local", + }); + const artifact = await postgresQuery<{ approval_review_json: string }>( + "SELECT approval_review_json::text AS approval_review_json FROM pending_consents WHERE device_code = $1", + [deviceCode] + ); + const reviewed = JSON.parse(String(artifact.rows[0]?.approval_review_json)) as { expires_at: string }; + await approveGrant(deviceCode, "owner_local", { + approval_review_revision: review?.reviewRevision, + }); + const grants = await issuedGrantRows(); + assert.equal(grants.length, 2); + assert.deepEqual( + grants.map((row) => row.expires_at), + [reviewed.expires_at, reviewed.expires_at] + ); + assert.deepEqual( + grants.map((row) => row.consumed), + [1, 1] + ); + } + ); +}); + +test("same pending row concurrent re-review invalidates the first revision", async () => { + await setup(); + const deviceCode = await stage({ instance_ids: [INSTANCE_ID], name: "top_artists", view: "basic" }); + const firstReview = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + getDb() + .prepare( + "UPDATE pending_consents SET approval_review_revision = ?, approval_review_digest = ? WHERE device_code = ?" + ) + .run("reference.approval-review.v1:sha256:changed", "sha256:changed", deviceCode); + + await assert.rejects( + () => + approveGrant(deviceCode, "owner_local", { + approval_review_revision: firstReview?.reviewRevision, + }), + /stale|subject changed|review/i + ); +}); + +test("request-time connector source ID must have explicit fulfillment", async () => { + await setup(); + await assert.rejects( + () => + initiateGrant({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: "https://registry.pdpp.dev/connectors/unregistered-source", kind: "connector" }, + streams: [{ name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }), + /Unknown source/ + ); +}); + +test("request source kind may be omitted and is derived from retained declaration", async () => { + await setup(); + const initiated = await initiateGrant( + { + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: SOURCE_ID }, + streams: [{ instance_ids: [INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }, + { nativeManifest: nativeManifest() } + ); + const deviceCode = parsePendingConsentRequestUri(initiated.request_uri); + assert.ok(deviceCode); + const pending = await getPendingConsent(deviceCode, { subjectId: "owner_local" }); + assert.deepEqual((pending as PendingReviewView | null)?.request?.source_binding, { + id: SOURCE_ID, + kind: "connector", + }); +}); diff --git a/reference-implementation/test/as-client-event-subscriptions-operation.test.ts b/reference-implementation/test/as-client-event-subscriptions-operation.test.ts index 58ba27c1b..d1b5fe6aa 100644 --- a/reference-implementation/test/as-client-event-subscriptions-operation.test.ts +++ b/reference-implementation/test/as-client-event-subscriptions-operation.test.ts @@ -82,8 +82,11 @@ function actor(overrides: Partial<BearerActor> = {}): BearerActor { clientId: "client_alpha", grantId: "grant_1", grantScope: { - source: { id: "gmail", kind: "connector" }, - streams: [{ name: "messages" }, { name: "contacts" }], + source: { connector_id: "gmail", id: "https://registry.pdpp.org/connectors/gmail", kind: "connector" }, + streams: [ + { instance_ids: ["gmail_default"], name: "messages" }, + { instance_ids: ["gmail_default"], name: "contacts" }, + ], }, subjectId: "owner_local", ...overrides, diff --git a/reference-implementation/test/as-consent-decision-outcome-pure.test.ts b/reference-implementation/test/as-consent-decision-outcome-pure.test.ts index 4b778f793..59067bad5 100644 --- a/reference-implementation/test/as-consent-decision-outcome-pure.test.ts +++ b/reference-implementation/test/as-consent-decision-outcome-pure.test.ts @@ -114,6 +114,7 @@ test("executeAsConsentDecision: approve surfaces package fields when the grant i test("executeAsConsentDecision: deny returns a success/deny outcome", async () => { const out = await executeAsConsentDecision(inputFor({ action: "deny", requestUri: "urn:req:dc-1" }), resolvingDeps()); assert.ok(out.outcome === "success" && out.action === "deny"); + assert.equal(out.requestUri, "urn:req:dc-1"); assert.ok(!("token" in out), "a deny has no token"); }); diff --git a/reference-implementation/test/as-device-decision-outcome-pure.test.ts b/reference-implementation/test/as-device-decision-outcome-pure.test.ts index dcd3cd934..fe8f9bbcd 100644 --- a/reference-implementation/test/as-device-decision-outcome-pure.test.ts +++ b/reference-implementation/test/as-device-decision-outcome-pure.test.ts @@ -147,6 +147,30 @@ test("executeAsDeviceDecision: a thrown store error is a 400 with the error code assert.equal(out.traceId, "tr"); }); +test("executeAsDeviceDecision: approval_conflict maps to HTTP 409 and preserves trace ids", async () => { + const out = await executeAsDeviceDecision( + inputFor({ action: "deny", userCode: "UC-CONFLICT" }), + baseDeps({ + // biome-ignore lint/suspicious/useAwait: async test double retains the Promise-returning dependency contract. + deny: async () => { + const e = new DeviceDecisionError("Pending consent approval conflict"); + e.code = "approval_conflict"; + e.request_id = "rq-conflict"; + e.trace_id = "tr-conflict"; + throw e; + }, + }) + ); + assert.deepEqual(out, { + errorCode: "approval_conflict", + errorMessage: "Pending consent approval conflict", + outcome: "failure", + requestId: "rq-conflict", + status: 409, + traceId: "tr-conflict", + }); +}); + test("executeAsDeviceDecision: a thrown error with no code/message uses the defaults", async () => { const out = await executeAsDeviceDecision( inputFor({ action: "deny", userCode: "UC-4" }), diff --git a/reference-implementation/test/as-oauth-token-cache-headers.test.ts b/reference-implementation/test/as-oauth-token-cache-headers.test.ts new file mode 100644 index 000000000..d4fe46ecc --- /dev/null +++ b/reference-implementation/test/as-oauth-token-cache-headers.test.ts @@ -0,0 +1,186 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { type MountAsTokenContext, mountAsToken } from "../server/routes/as-oauth.ts"; + +interface TokenRequest { + grant_type: string; + [key: string]: unknown; +} + +class ResponseProbe { + readonly headers = new Map<string, string>(); + body: unknown; + statusCode = 200; + + setHeader(name: string, value: string): this { + this.headers.set(name, value); + return this; + } + + status(code: number): this { + this.statusCode = code; + return this; + } + + json(body: unknown): this { + this.body = body; + return this; + } +} + +function contextFor(options: { + exchangeAuthorizationCode?: MountAsTokenContext["exchangeOAuthAuthorizationCode"]; + exchangeDeviceCode?: MountAsTokenContext["exchangeDeviceCode"]; + exchangeRefreshToken?: MountAsTokenContext["exchangeOAuthRefreshToken"]; +}): MountAsTokenContext { + return { + exchangeDeviceCode: options.exchangeDeviceCode ?? (() => ({ access_token: "device-access", token_type: "Bearer" })), + exchangeOAuthAuthorizationCode: + options.exchangeAuthorizationCode ?? + (() => Promise.resolve({ access_token: "code-access", token_type: "Bearer" })), + exchangeOAuthRefreshToken: + options.exchangeRefreshToken ?? + (() => Promise.resolve({ access_token: "refresh-access", refresh_token: "refresh-next", token_type: "Bearer" })), + oauthError: (_res, status, errorCode, errorMessage) => ({ errorCode, errorMessage, status }), + resolveBaseUrl: () => "https://as.example", + setReferenceTraceId: () => undefined, + }; +} + +async function invokeTokenRoute(body: TokenRequest, ctx: MountAsTokenContext): Promise<ResponseProbe> { + let handler: + | (( + req: { body: TokenRequest; get: (name: string) => string | undefined; protocol: string }, + res: ResponseProbe + ) => Promise<unknown>) + | undefined; + const app = { + post: (_path: string, ...args: unknown[]) => { + handler = args.at(-1) as typeof handler; + return app; + }, + }; + mountAsToken(app as Parameters<typeof mountAsToken>[0], ctx); + assert.ok(handler, "token route handler should be registered"); + const response = new ResponseProbe(); + await handler({ body, get: () => undefined, protocol: "https" }, response); + return response; +} + +const CACHE_HEADERS = { + "Cache-Control": "no-store", + Pragma: "no-cache", +}; + +test("every successful token response branch sets OAuth cache-prevention headers", async () => { + const cases: Array<{ + name: string; + body: TokenRequest; + context: MountAsTokenContext; + expectedToken: string; + }> = [ + { + body: { client_id: "client", code: "code", grant_type: "authorization_code" }, + context: contextFor({ + exchangeAuthorizationCode: async () => ({ + access_token: "code-access", + grant_id: "grant-1", + token_type: "Bearer", + }), + }), + expectedToken: "code-access", + name: "authorization code grant", + }, + { + body: { client_id: "client", code: "code", grant_type: "authorization_code" }, + context: contextFor({ + exchangeAuthorizationCode: async () => ({ + access_token: "package-code-access", + grant_package_id: "package-1", + token_type: "Bearer", + }), + }), + expectedToken: "package-code-access", + name: "authorization code package", + }, + { + body: { client_id: "client", grant_type: "refresh_token", refresh_token: "refresh" }, + context: contextFor({ + exchangeRefreshToken: async () => ({ + access_token: "refresh-access", + grant_id: "grant-1", + refresh_token: "refresh-next", + token_type: "Bearer", + }), + }), + expectedToken: "refresh-access", + name: "refresh grant", + }, + { + body: { client_id: "client", grant_type: "refresh_token", refresh_token: "refresh" }, + context: contextFor({ + exchangeRefreshToken: async () => ({ + access_token: "package-refresh-access", + grant_package_id: "package-1", + refresh_token: "package-refresh-next", + token_type: "Bearer", + }), + }), + expectedToken: "package-refresh-access", + name: "refresh package", + }, + { + body: { + client_id: "client", + device_code: "device", + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }, + context: contextFor({ + exchangeDeviceCode: async () => ({ + access_token: "device-access", + grant_package_id: "package-1", + refresh_token: "device-refresh", + token_type: "Bearer", + trace_context: { request_id: "request", trace_id: "trace" }, + }), + }), + expectedToken: "device-access", + name: "device code", + }, + ]; + + await Promise.all( + cases.map(async (scenario) => { + const response = await invokeTokenRoute(scenario.body, scenario.context); + assert.equal(response.statusCode, 200, scenario.name); + for (const [name, value] of Object.entries(CACHE_HEADERS)) { + assert.equal(response.headers.get(name), value, `${scenario.name} ${name}`); + } + assert.equal((response.body as Record<string, unknown>).access_token, scenario.expectedToken, scenario.name); + }) + ); +}); + +test("token errors and unsupported grants do not receive token-success headers", async () => { + const errorResponse = await invokeTokenRoute( + { client_id: "client", code: "reused", grant_type: "authorization_code" }, + contextFor({ + exchangeAuthorizationCode: () => { + const error = new Error("code already used") as Error & { code: string }; + error.code = "invalid_grant"; + throw error; + }, + }) + ); + assert.deepEqual(Object.fromEntries(errorResponse.headers), {}); + assert.equal(errorResponse.statusCode, 200, "the stubbed OAuth error does not alter status"); + + const unsupportedResponse = await invokeTokenRoute( + { client_id: "client", grant_type: "client_credentials" }, + contextFor({}) + ); + assert.deepEqual(Object.fromEntries(unsupportedResponse.headers), {}); +}); diff --git a/reference-implementation/test/as-operations.test.ts b/reference-implementation/test/as-operations.test.ts index 312a686e1..2ebee2e5c 100644 --- a/reference-implementation/test/as-operations.test.ts +++ b/reference-implementation/test/as-operations.test.ts @@ -741,7 +741,7 @@ test("as.consent.decision resolves approval_id to request_uri via build helper", assert.equal(resolvedUri, "urn:par:dev_1"); }); -test("as.consent.decision returns 404 for non-pending approval_id", async () => { +test("as.consent.decision returns 404 for a denied approval_id", async () => { const outcome = await executeAsConsentDecision( { action: "approve", @@ -750,7 +750,7 @@ test("as.consent.decision returns 404 for non-pending approval_id", async () => subjectId: "s", }, makeConsentDeps({ - getPendingConsentByApprovalId: () => ({ device_code: "d", status: "approved" }), + getPendingConsentByApprovalId: () => ({ device_code: "d", status: "denied" }), }) ); assert.equal(outcome.outcome, "failure"); @@ -791,9 +791,8 @@ test("as.consent.decision approve returns grant + token", async () => { { action: "approve", approvalId: null, - approveOptions: { ai_training_consented: true }, + approveOptions: { approval_review_revision: "reference.approval-review.v1:sha256:test" }, requestUri: "urn:par:dev_1", - subjectId: "owner", }, makeConsentDeps({ approveGrant: (deviceCode, subjectId, opts) => { @@ -816,8 +815,8 @@ test("as.consent.decision approve returns grant + token", async () => { assert.equal(outcome.grant.grant_id, "g1"); assert.deepEqual(approveArgs, { deviceCode: "dev_1", - opts: { ai_training_consented: true }, - subjectId: "owner", + opts: { approval_review_revision: "reference.approval-review.v1:sha256:test" }, + subjectId: undefined, }); }); diff --git a/reference-implementation/test/as-par-error-mapping.test.ts b/reference-implementation/test/as-par-error-mapping.test.ts new file mode 100644 index 000000000..b572bdd95 --- /dev/null +++ b/reference-implementation/test/as-par-error-mapping.test.ts @@ -0,0 +1,30 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mapParProtocolError } from "../server/routes/as-par.ts"; + +test("mapParProtocolError: maps source validation without mutating the source error", () => { + const source = Object.assign(new Error("authorization_details is invalid"), { + code: "source.authorization_details_invalid", + param: "authorization_details", + request_id: "req-123", + statusCode: 400, + trace_id: "trace-456", + }); + + const mapped = mapParProtocolError(source); + + assert.ok(mapped instanceof Error); + assert.notStrictEqual(mapped, source); + assert.equal(source.code, "source.authorization_details_invalid"); + assert.equal((mapped as Error & { code?: string }).code, "invalid_authorization_details"); + assert.equal(mapped.message, source.message); + assert.equal((mapped as Error & { param?: string }).param, source.param); + assert.equal((mapped as Error & { request_id?: string }).request_id, source.request_id); + assert.equal((mapped as Error & { statusCode?: number }).statusCode, source.statusCode); + assert.equal((mapped as Error & { trace_id?: string }).trace_id, source.trace_id); + assert.equal(mapped.cause, source); +}); diff --git a/reference-implementation/test/assistant-readiness-smoke.test.ts b/reference-implementation/test/assistant-readiness-smoke.test.ts index 76e9ed78d..42abad5cd 100644 --- a/reference-implementation/test/assistant-readiness-smoke.test.ts +++ b/reference-implementation/test/assistant-readiness-smoke.test.ts @@ -366,6 +366,7 @@ test("assistant smoke: in-memory fallback activates for stale DB cursor_field dr connector_id: "fallback-smoke", connector_key: "fallback-smoke", display_name: "Fallback smoke", + manifest_uri: "https://sources.example/connectors/fallback-smoke", // Custom (non-first-party) manifest: connector_id must be a bare slug // that matches connector_key. The registry URL belongs in manifest_uri, // not connector_id. See canonicalize-connector-keys (connector_id == diff --git a/reference-implementation/test/auth-consent-device-postgres-path.test.ts b/reference-implementation/test/auth-consent-device-postgres-path.test.ts index 9c16d643a..6006e2ff7 100644 --- a/reference-implementation/test/auth-consent-device-postgres-path.test.ts +++ b/reference-implementation/test/auth-consent-device-postgres-path.test.ts @@ -21,7 +21,7 @@ * adapters (`postgresPendingConsentStore` and `postgresOwnerDeviceAuthStore`) * actually execute: * - createOwnerDeviceAuth / getOwnerDeviceAuthRowByUserCode / - * markOwnerDeviceAuthApproved / getOwnerDeviceAuthRow (owner device flow) + * approveAtomically / getOwnerDeviceAuthRow (owner device flow) * - createPendingConsent / getPendingConsentRow (incl. the * `params_json::text` cast) / markPendingConsentApproved (consent flow) * @@ -41,20 +41,29 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { + type AuthorizationDecisionFaultHook, approveGrant, approveOwnerDeviceAuthorization, + consumeConsentExchangeCode, + createConsentExchangeCode, + createHostedMcpGrantPackage, + denyGrant, denyOwnerDeviceAuthorization, exchangeOwnerDeviceCode, getOwnerDeviceAuthorizationByUserCode, getPendingConsent, initiateGrant, initiateOwnerDeviceAuthorization, + introspect, + issueToken, parsePendingConsentRequestUri, registerConnector, + revokeGrantPackage, seedPreRegisteredClients, } from "../server/auth.ts"; import { closeDb, initDb } from "../server/db.ts"; -import { closePostgresStorage, initPostgresStorage } from "../server/postgres-storage.ts"; +import { closePostgresStorage, initPostgresStorage, postgresQuery } from "../server/postgres-storage.ts"; +import { createPostgresConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; @@ -70,11 +79,73 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); const CONSOLE_CLIENT_ID = "pg_path_console"; - +const POSTGRES_AUTH_INSTANCE_ID = "cin_pg_auth_source_snapshot_0811"; +const FORCED_POSTGRES_AFTER_TOKEN_INSERT_RE = /forced postgres after_token_insert/; +const FORCED_POSTGRES_DENIAL_ROLLBACK_RE = /forced postgres denial rollback/; +const GRANT_BINDING_RE = /Grant is malformed|grant/i; +const PROJECTED_DECLARATION_VERSION_RE = /^reference\.legacy-connector-projection\.v1:sha256:[0-9a-f]{64}$/; function loadSpotifyManifest() { return JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8")); } +function createDecisionPause(): { paused: Promise<void>; release: () => void; hook: () => Promise<void> } { + let release: () => void = () => undefined; + let markPaused: () => void = () => undefined; + const paused = new Promise<void>((resolve) => { + markPaused = resolve; + }); + const resumed = new Promise<void>((resolve) => { + release = resolve; + }); + return { + hook: async () => { + markPaused(); + await resumed; + }, + paused, + release, + }; +} + +async function startReviewedPendingConsent(): Promise<{ deviceCode: string; reviewRevision: string }> { + const manifest = loadSpotifyManifest(); + const initiated = await initiateGrant({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + purpose_description: "atomic terminal decision postgres proof", + source: { id: manifest.connector_id, kind: "connector" }, + streams: [{ instance_ids: [POSTGRES_AUTH_INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CONSOLE_CLIENT_ID, + }); + const deviceCode = parsePendingConsentRequestUri(initiated.request_uri); + assert.ok(deviceCode); + const pending = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + const reviewRevision = pending?.reviewRevision; + assert.equal(typeof reviewRevision, "string"); + return { deviceCode, reviewRevision: reviewRevision as string }; +} + +async function upsertPostgresAuthFixtureInstance(): Promise<void> { + const now = new Date().toISOString(); + await createPostgresConnectorInstanceStore().upsert({ + connectorId: "spotify", + connectorInstanceId: POSTGRES_AUTH_INSTANCE_ID, + createdAt: now, + displayName: "Postgres auth path fixture", + ownerSubjectId: "owner_local", + sourceBinding: { fixture: POSTGRES_AUTH_INSTANCE_ID }, + sourceBindingKey: POSTGRES_AUTH_INSTANCE_ID, + sourceKind: "manual", + status: "active", + updatedAt: now, + }); +} + if (POSTGRES_URL) { // --------------------------------------------------------------------- // Shared setup. The SQLite handle is opened in-memory only so that @@ -100,10 +171,20 @@ if (POSTGRES_URL) { registration_mode: "pre_registered_public", }, ]); + await upsertPostgresAuthFixtureInstance(); setupOk = true; }); + test.beforeEach(async () => { + if (setupOk) { + await upsertPostgresAuthFixtureInstance(); + } + }); + test.after(async () => { + await postgresQuery("DELETE FROM connector_instances WHERE connector_instance_id = $1", [ + POSTGRES_AUTH_INSTANCE_ID, + ]); await closePostgresStorage(); closeDb(); }); @@ -112,8 +193,8 @@ if (POSTGRES_URL) { // A) Owner-device-authorization flow. // // Exercises the postgresOwnerDeviceAuthStore adapter: insert (createOwnerDeviceAuth), - // getByUserCode (getOwnerDeviceAuthRowByUserCode), markApproved - // (markOwnerDeviceAuthApproved), getByDeviceCode (getOwnerDeviceAuthRow). + // getByUserCode (getOwnerDeviceAuthRowByUserCode), approveAtomically, + // getByDeviceCode (getOwnerDeviceAuthRow). // --------------------------------------------------------------------- test("owner device authorization: approve + exchange through real auth.js postgres adapters", async () => { assert.equal(setupOk, true, "before() setup must have completed"); @@ -148,6 +229,149 @@ if (POSTGRES_URL) { assert.equal(exchanged.access_token, approved.access_token, "exchanged token is the token bound at approval"); }); + test("owner device authorization: atomic approval rolls back faults and is retry-idempotent on postgres", async () => { + assert.equal(setupOk, true, "before() setup must have completed"); + + const failed = await initiateOwnerDeviceAuthorization(CONSOLE_CLIENT_ID, { + expiresIn: 300, + interval: 1, + }); + assert.equal(typeof failed.user_code, "string"); + assert.equal(typeof failed.device_code, "string"); + const ownerTokenCountBeforeFault = await postgresQuery<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM tokens WHERE client_id = $1 AND token_kind = 'owner'", + [CONSOLE_CLIENT_ID] + ); + + await assert.rejects( + approveOwnerDeviceAuthorization(failed.user_code, "owner_local", { + faultHook: (stage) => { + if (stage === "after_token_insert") { + throw new Error("forced postgres after_token_insert"); + } + }, + }), + FORCED_POSTGRES_AFTER_TOKEN_INSERT_RE + ); + + const failedRow = await postgresQuery<{ status: string; token_id: string | null }>( + "SELECT status, token_id FROM owner_device_auth WHERE device_code = $1", + [failed.device_code] + ); + assert.deepEqual(failedRow.rows[0], { status: "pending", token_id: null }); + const orphanCount = await postgresQuery<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM tokens WHERE client_id = $1 AND token_kind = 'owner'", + [CONSOLE_CLIENT_ID] + ); + assert.equal(orphanCount.rows[0]?.count, ownerTokenCountBeforeFault.rows[0]?.count, "fault leaves no owner token"); + + const recovered = await approveOwnerDeviceAuthorization(failed.user_code, "owner_local"); + assert.equal(typeof recovered.access_token, "string"); + const retry = await approveOwnerDeviceAuthorization(failed.user_code, "owner_local"); + assert.equal(retry.access_token, recovered.access_token, "retry returns the bound token"); + + const concurrentStarted = await initiateOwnerDeviceAuthorization(CONSOLE_CLIENT_ID, { + expiresIn: 300, + interval: 1, + }); + assert.equal(typeof concurrentStarted.user_code, "string"); + const approvals = await Promise.all( + Array.from({ length: 8 }, () => approveOwnerDeviceAuthorization(concurrentStarted.user_code, "owner_local")) + ); + const tokens = new Set(approvals.map((approval) => approval.access_token)); + assert.equal(tokens.size, 1, "concurrent postgres approvals return one token"); + + const recoveredApprovalEvents = await postgresQuery<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM spine_events + WHERE object_id = $1 + AND object_type = 'owner_device_auth' + AND event_type = 'consent.approved'`, + [failed.device_code] + ); + assert.equal(recoveredApprovalEvents.rows[0]?.count, "1"); + const recoveredTokenEvents = await postgresQuery<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM spine_events + WHERE token_id = $1 + AND object_type = 'token' + AND event_type = 'token.issued'`, + [recovered.access_token] + ); + assert.equal(recoveredTokenEvents.rows[0]?.count, "1"); + + const tokenState = await introspect(recovered.access_token); + assert.equal(tokenState.active, true, "recovered postgres owner token introspects active"); + assert.equal(tokenState.pdpp_token_kind, "owner"); + }); + + test("owner device authorization: approved recovery rejects a different subject on postgres", async () => { + assert.equal(setupOk, true, "before() setup must have completed"); + + const initiated = await initiateOwnerDeviceAuthorization(CONSOLE_CLIENT_ID, { + expiresIn: 300, + interval: 1, + }); + assert.equal(typeof initiated.user_code, "string"); + assert.equal(typeof initiated.device_code, "string"); + + const ownerA = await approveOwnerDeviceAuthorization(initiated.user_code, "owner_A"); + assert.equal(ownerA.subject_id, "owner_A"); + await assert.rejects(approveOwnerDeviceAuthorization(initiated.user_code, "owner_B"), (err) => { + assert.ok(isDeviceAuthError(err), "rejection is an Error"); + assert.equal(err.code, "not_found", "cross-subject recovery is hidden"); + return true; + }); + + const ownerRows = await postgresQuery<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM tokens WHERE client_id = $1 AND token_kind = 'owner'", + [CONSOLE_CLIENT_ID] + ); + assert.equal(Number(ownerRows.rows[0]?.count) >= 1, true, "owner token rows remain queryable"); + const row = await postgresQuery<{ status: string; subject_id: string | null; token_id: string | null }>( + "SELECT status, subject_id, token_id FROM owner_device_auth WHERE device_code = $1", + [initiated.device_code] + ); + assert.deepEqual(row.rows[0], { status: "approved", subject_id: "owner_A", token_id: ownerA.access_token }); + }); + + test("owner device authorization: mixed concurrent subjects produce one postgres owner token", async () => { + assert.equal(setupOk, true, "before() setup must have completed"); + + const initiated = await initiateOwnerDeviceAuthorization(CONSOLE_CLIENT_ID, { + expiresIn: 300, + interval: 1, + }); + assert.equal(typeof initiated.user_code, "string"); + const attempts = await Promise.allSettled( + Array.from({ length: 8 }, (_, index) => + approveOwnerDeviceAuthorization(initiated.user_code, index % 2 === 0 ? "owner_A" : "owner_B") + ) + ); + const approvals = attempts + .filter((attempt): attempt is PromiseFulfilledResult<Record<string, unknown>> => attempt.status === "fulfilled") + .map((attempt) => attempt.value); + assert.ok(approvals.length >= 1, "one subject claims the row"); + assert.ok(approvals.length <= 4, "only the claimed subject recovers"); + assert.equal(new Set(approvals.map((approval) => approval.subject_id)).size, 1); + assert.equal(new Set(approvals.map((approval) => approval.access_token)).size, 1); + + const row = await postgresQuery<{ status: string; subject_id: string | null; token_id: string | null }>( + "SELECT status, subject_id, token_id FROM owner_device_auth WHERE device_code = $1", + [initiated.device_code] + ); + assert.deepEqual(row.rows[0], { + status: "approved", + subject_id: approvals[0]?.subject_id as string, + token_id: approvals[0]?.access_token as string, + }); + const tokenRows = await postgresQuery<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM tokens WHERE token_id = $1", + [approvals[0]?.access_token] + ); + assert.equal(tokenRows.rows[0]?.count, "1", "claimed token is stored once"); + }); + test("owner device authorization: deny then exchange fails through real auth.js postgres adapters", async () => { assert.equal(setupOk, true, "before() setup must have completed"); @@ -157,7 +381,7 @@ if (POSTGRES_URL) { }); assert.ok(initiated.device_code, "second initiate returns a device_code"); - // Deny: markDenied (PG UPDATE). + // Deny: markDeniedAtomically (PG UPDATE + denial event transaction). await denyOwnerDeviceAuthorization(initiated.user_code); // Exchange against a denied row must be rejected. getByDeviceCode (PG @@ -177,6 +401,31 @@ if (POSTGRES_URL) { ); }); + test("owner device authorization: approve and deny arbitrate one terminal decision on postgres", async () => { + const approvalWins = await initiateOwnerDeviceAuthorization(CONSOLE_CLIENT_ID, { expiresIn: 300, interval: 1 }); + const pause = createDecisionPause(); + const denial = denyOwnerDeviceAuthorization(approvalWins.user_code, "owner_local", { + beforeCasHook: pause.hook, + }); + await pause.paused; + const approved = await approveOwnerDeviceAuthorization(approvalWins.user_code, "owner_local"); + pause.release(); + await assert.rejects(denial, (err: unknown) => isDeviceAuthError(err) && err.code === "approval_conflict"); + assert.equal((await introspect(approved.access_token)).active, true); + const losingDenialEvents = await postgresQuery<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM spine_events WHERE object_id = $1 AND event_type = 'request.rejected'", + [approvalWins.device_code] + ); + assert.equal(losingDenialEvents.rows[0]?.count, "0"); + + const denialWins = await initiateOwnerDeviceAuthorization(CONSOLE_CLIENT_ID, { expiresIn: 300, interval: 1 }); + await denyOwnerDeviceAuthorization(denialWins.user_code, "owner_local"); + await assert.rejects( + approveOwnerDeviceAuthorization(denialWins.user_code, "owner_local"), + (err: unknown) => isDeviceAuthError(err) && err.code === "approval_conflict" + ); + }); + // Expiry / markExpired is not driven here: the only public seam to force a // row past its TTL is a direct row UPDATE on expires_at, which the SQLite // and Postgres conformance drivers expose as a test-only seam. Reproducing @@ -211,7 +460,7 @@ if (POSTGRES_URL) { purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "consent-device-auth postgres-path proof", source: { id: manifest.connector_id, kind: "connector" }, - streams: [{ name: "top_artists", view: "basic" }], + streams: [{ instance_ids: [POSTGRES_AUTH_INSTANCE_ID], name: "top_artists", view: "basic" }], type: "https://pdpp.dev/data-access", }, ], @@ -224,19 +473,100 @@ if (POSTGRES_URL) { // getPendingConsent -> getPendingConsentRow -> Postgres getByDeviceCode, // which reads params_json via the ::text cast and JSON.parse()s it. - const pending = await getPendingConsent(deviceCode); + const pending = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); assert.ok(pending, "pending consent request is returned"); assert.ok(pending.request, "pending consent carries the parsed request (params_json round-trip)"); assert.equal(pending.userCode, initiated.user_code, "pending userCode matches the initiated user_code"); + const pendingRequest = pending.request as { + source_declaration_snapshot?: { + declaration?: { + declaration_version?: string; + publisher?: { id?: string }; + source?: { id?: string; kind?: string }; + }; + declaration_version?: string; + snapshot_version?: string; + source?: { id?: string; kind?: string }; + }; + }; + assert.equal( + pendingRequest.source_declaration_snapshot?.snapshot_version, + "reference.source-declaration-snapshot.v1" + ); + const declarationVersion = pendingRequest.source_declaration_snapshot?.declaration_version; + assert.match(declarationVersion ?? "", PROJECTED_DECLARATION_VERSION_RE); + assert.deepEqual(pendingRequest.source_declaration_snapshot?.source, { + id: manifest.connector_id, + kind: "connector", + }); + assert.deepEqual(pendingRequest.source_declaration_snapshot?.declaration?.source, { + id: manifest.connector_id, + kind: "connector", + }); + assert.deepEqual(pendingRequest.source_declaration_snapshot?.declaration?.publisher, { + id: "https://pdpp.dev/reference-implementation", + }); + assert.equal(pendingRequest.source_declaration_snapshot?.declaration?.declaration_version, declarationVersion); // Approve: markPendingConsentApproved (PG UPDATE) + issues the grant. - const approved = await approveGrant(deviceCode, "owner_local"); + assert.equal(typeof pending.reviewRevision, "string", "review materializes an approval revision"); + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: pending.reviewRevision, + }); assert.ok(approved, "approveGrant resolves"); // approveGrant's two branches (single grant / staged batch package) both // return { grant: { grant_id }, token, ... }; there is no top-level // grant_id/access_token field on either shape. - // biome-ignore lint/suspicious/noUnnecessaryConditions: assertion retains its defensive runtime boundary - assert.ok(approved.grant?.grant_id || approved.token, "approveGrant yields a grant / token result"); + assert.ok(approved.grant.grant_id || approved.token, "approveGrant yields a grant / token result"); + const persistedGrant = await postgresQuery<{ grant_json: Record<string, unknown> }>( + "SELECT grant_json FROM grants WHERE grant_id = $1", + [approved.grant.grant_id] + ); + const grantJson = persistedGrant.rows[0]?.grant_json; + assert.ok(grantJson, "Postgres retains the issued resolved grant JSON"); + const grantStreams = grantJson.streams as { + fields?: string[]; + instance_ids?: string[]; + name?: string; + }[]; + assert.ok(grantStreams[0]?.fields?.length, "issued stream freezes explicit fields"); + assert.equal(grantStreams[0]?.instance_ids?.length, 1); + assert.notEqual(grantStreams[0]?.instance_ids?.[0], "spotify"); + assert.deepEqual(grantJson.source_declaration, { + version: declarationVersion, + }); + + const tokenCountBefore = await postgresQuery<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM tokens WHERE grant_id = $1", + [approved.grant.grant_id] + ); + await assert.rejects( + () => + issueToken( + approved.grant.grant_id as string, + "owner_local", + "client_tampered", + approved.grant.expires_at ?? null + ), + GRANT_BINDING_RE + ); + const tokenCountAfter = await postgresQuery<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM tokens WHERE grant_id = $1", + [approved.grant.grant_id] + ); + assert.equal(tokenCountAfter.rows[0]?.count, tokenCountBefore.rows[0]?.count); + + const tamperedGrant = structuredClone(grantJson); + tamperedGrant.client = { client_id: "client_tampered" }; + await postgresQuery("UPDATE grants SET grant_json = $1::jsonb WHERE grant_id = $2", [ + JSON.stringify(tamperedGrant), + approved.grant.grant_id, + ]); + assert.equal((await introspect(approved.token)).active, false, "Postgres grant-column mismatch must fail closed"); + await postgresQuery("UPDATE grants SET grant_json = $1::jsonb WHERE grant_id = $2", [ + JSON.stringify(grantJson), + approved.grant.grant_id, + ]); // After approval the row is no longer pending; the public getPendingConsent // view (which filters on status='pending') returns null. This re-reads @@ -244,6 +574,226 @@ if (POSTGRES_URL) { const afterApproval = await getPendingConsent(deviceCode); assert.equal(afterApproval, null, "approved consent is no longer pending"); }); + + test("pending consent: approve and deny arbitrate atomically with rollback on postgres", async () => { + const approvalWins = await startReviewedPendingConsent(); + const pause = createDecisionPause(); + const denial = denyGrant(approvalWins.deviceCode, { beforeCasHook: pause.hook }); + await pause.paused; + const approved = await approveGrant(approvalWins.deviceCode, "owner_local", { + approval_review_revision: approvalWins.reviewRevision, + }); + pause.release(); + await assert.rejects(denial, (err: unknown) => isDeviceAuthError(err) && err.code === "approval_conflict"); + assert.equal((await introspect(approved.token)).active, true); + + const rollback = await startReviewedPendingConsent(); + const faultHook: AuthorizationDecisionFaultHook = (stage) => { + if (stage === "after_event_before_commit") { + throw new Error("forced postgres denial rollback"); + } + }; + await assert.rejects(denyGrant(rollback.deviceCode, { faultHook }), FORCED_POSTGRES_DENIAL_ROLLBACK_RE); + assert.ok(await getPendingConsent(rollback.deviceCode), "rolled-back denial remains pending"); + + const denialWins = await startReviewedPendingConsent(); + assert.equal(await denyGrant(denialWins.deviceCode), true); + await assert.rejects( + approveGrant(denialWins.deviceCode, "owner_local", { + approval_review_revision: denialWins.reviewRevision, + }), + (err: unknown) => isDeviceAuthError(err) && err.code === "approval_conflict" + ); + }); + + test("pre-Source v1 package token requires fresh consent through the real Postgres introspection path", async () => { + const manifest = loadSpotifyManifest(); + const result = await createHostedMcpGrantPackage({ + authorizationDetails: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personal_ai_assistant", + source: { id: manifest.connector_id, kind: "connector" }, + streams: [{ instance_ids: [POSTGRES_AUTH_INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + clientId: CONSOLE_CLIENT_ID, + connectionIds: [POSTGRES_AUTH_INSTANCE_ID], + storageBindings: [{ connector_id: "spotify" }], + }); + const packageId = result.package_id as string; + const currentEnvelope = await postgresQuery<{ package_json: Record<string, unknown> }>( + "SELECT package_json FROM grant_packages WHERE package_id = $1", + [packageId] + ); + const preSourceEnvelope = structuredClone(currentEnvelope.rows[0]?.package_json ?? {}); + preSourceEnvelope.version = "reference.mcp_package.v1"; + await postgresQuery("UPDATE grant_packages SET package_json = $1::jsonb WHERE package_id = $2", [ + JSON.stringify(preSourceEnvelope), + packageId, + ]); + + const tokenState = await introspect(result.token); + assert.equal(tokenState.active, false); + assert.equal(tokenState.inactive_reason, "package_invalid"); + }); + + test("consent handoff: concurrent Postgres redemption converges on one persisted token", async () => { + const manifest = loadSpotifyManifest(); + const initiated = await initiateGrant({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: manifest.connector_id, kind: "connector" }, + streams: [{ instance_ids: [POSTGRES_AUTH_INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CONSOLE_CLIENT_ID, + }); + const deviceCode = parsePendingConsentRequestUri(initiated.request_uri); + assert.ok(deviceCode); + const pending = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + assert.ok(pending); + assert.equal(typeof pending.reviewRevision, "string"); + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: pending.reviewRevision, + }); + const code = await createConsentExchangeCode({ + grant: approved.grant, + grantId: approved.grant.grant_id as string, + token: approved.token, + }); + const attempts = await Promise.all(Array.from({ length: 8 }, () => consumeConsentExchangeCode(code))); + const successes = attempts.filter((attempt) => attempt.ok); + const consumed = attempts.filter((attempt) => !attempt.ok && attempt.reason === "consumed"); + assert.equal(successes.length, 1); + assert.equal(consumed.length, 7); + assert.equal(successes[0]?.token, approved.token); + assert.equal(successes[0]?.grantId, approved.grant.grant_id); + const stored = await postgresQuery<{ count: string; redeemed_count: string }>( + `SELECT COUNT(*)::text AS count, + COUNT(redeemed_at)::text AS redeemed_count + FROM consent_exchange_codes + WHERE token_id = $1`, + [approved.token] + ); + assert.deepEqual(stored.rows[0], { count: "1", redeemed_count: "1" }); + + const replay = await consumeConsentExchangeCode(code); + assert.equal(replay.ok, false); + assert.equal(replay.reason, "consumed"); + }); + + test("consent handoff: Postgres response-loss retry succeeds only with the same bound proof", async () => { + const manifest = loadSpotifyManifest(); + const initiated = await initiateGrant({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: manifest.connector_id, kind: "connector" }, + streams: [{ instance_ids: [POSTGRES_AUTH_INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CONSOLE_CLIENT_ID, + }); + const deviceCode = parsePendingConsentRequestUri(initiated.request_uri); + assert.ok(deviceCode); + const pending = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + assert.ok(pending?.reviewRevision); + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: pending.reviewRevision, + }); + const proof = "postgres-bound-proof"; + const code = await createConsentExchangeCode({ + grant: approved.grant, + grantId: approved.grant.grant_id as string, + recoveryProof: proof, + token: approved.token, + }); + const first = await consumeConsentExchangeCode(code, proof); + assert.equal(first.ok, true); + const retry = await consumeConsentExchangeCode(code, proof); + assert.deepEqual(retry, first); + const wrongProof = await consumeConsentExchangeCode(code, "wrong-proof"); + assert.equal(wrongProof.ok, false); + assert.equal(wrongProof.reason, "consumed"); + }); + + test("consent handoff: Postgres reissue invalidates older outstanding codes", async () => { + const manifest = loadSpotifyManifest(); + const initiated = await initiateGrant({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: manifest.connector_id, kind: "connector" }, + streams: [{ instance_ids: [POSTGRES_AUTH_INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CONSOLE_CLIENT_ID, + }); + const deviceCode = parsePendingConsentRequestUri(initiated.request_uri); + assert.ok(deviceCode); + const pending = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + assert.ok(pending?.reviewRevision); + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: pending.reviewRevision, + }); + const firstCode = await createConsentExchangeCode({ + grant: approved.grant, + grantId: approved.grant.grant_id as string, + token: approved.token, + }); + const secondCode = await createConsentExchangeCode({ + grant: approved.grant, + grantId: approved.grant.grant_id as string, + token: approved.token, + }); + const first = await consumeConsentExchangeCode(firstCode); + assert.equal(first.ok, false); + assert.equal(first.reason, "expired"); + const second = await consumeConsentExchangeCode(secondCode); + assert.equal(second.ok, true); + assert.equal(second.token, approved.token); + }); + + test("consent handoff: Postgres package delivery works and revocation fails closed", async () => { + const manifest = loadSpotifyManifest(); + const created = await createHostedMcpGrantPackage({ + authorizationDetails: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personal_ai_assistant", + source: { id: manifest.connector_id, kind: "connector" }, + streams: [{ instance_ids: [POSTGRES_AUTH_INSTANCE_ID], name: "top_artists", view: "basic" }], + type: "https://pdpp.dev/data-access", + }, + ], + clientId: CONSOLE_CLIENT_ID, + connectionIds: [POSTGRES_AUTH_INSTANCE_ID], + storageBindings: [{ connector_id: "spotify" }], + }); + const packageId = created.package_id as string; + const grant = created.package as Record<string, unknown>; + const firstCode = await createConsentExchangeCode({ grant, grantId: packageId, token: created.token as string }); + const delivered = await consumeConsentExchangeCode(firstCode); + assert.equal(delivered.ok, true); + assert.equal(delivered.packageId, packageId); + assert.equal(delivered.token, created.token); + + const revokedCode = await createConsentExchangeCode({ grant, grantId: packageId, token: created.token as string }); + await revokeGrantPackage(packageId); + const rejected = await consumeConsentExchangeCode(revokedCode); + assert.equal(rejected.ok, false); + assert.equal(rejected.reason, "revoked"); + assert.equal(rejected.token, undefined); + }); } else { test("auth.js consent/owner-device-auth postgres-adapter path (skipped: PDPP_TEST_POSTGRES_URL unset)", { skip: true, diff --git a/reference-implementation/test/b3-introspection-resources-conformance.test.ts b/reference-implementation/test/b3-introspection-resources-conformance.test.ts index 3893728e8..44726cb54 100644 --- a/reference-implementation/test/b3-introspection-resources-conformance.test.ts +++ b/reference-implementation/test/b3-introspection-resources-conformance.test.ts @@ -22,11 +22,24 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; +import { basicIntrospectionAuthorization } from "../server/introspection-http.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; +import { makeDefaultAccountConnectorInstanceId } from "../server/stores/connector-instance-store.ts"; +import { + TEST_INTROSPECTION_SERVER_OPTS, + TEST_RS_INTROSPECTION_CREDENTIALS, +} from "./helpers/introspection-test-credentials.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); const MANIFESTS_DIR = join(REFERENCE_IMPL_DIR, "manifests"); +const INTROSPECTION_AUTHORIZATION = basicIntrospectionAuthorization(TEST_RS_INTROSPECTION_CREDENTIALS); + +function introspectionHeaders(contentType = "application/json"): Record<string, string> { + return { Authorization: INTROSPECTION_AUTHORIZATION, "Content-Type": contentType }; +} // ─── shared helpers ───────────────────────────────────────────────────────── @@ -91,7 +104,7 @@ interface ApprovedGrant { grant: { access_mode: string; grant_id: string; - source?: { kind: string }; + source?: { id?: string; kind: string }; streams: readonly ApprovedGrantStream[]; }; token: string; @@ -108,16 +121,27 @@ interface IssueClientGrantParams { interface IntrospectionBody { active: boolean; + authorization_details?: readonly { + access_mode?: string; + source?: { id?: string; kind?: string }; + streams?: readonly ApprovedGrantStream[]; + type?: string; + }[]; client_id?: string; - exp?: number | null; + exp?: number; grant?: { access_mode?: string; grant_id?: string; - source?: { kind?: string }; + source?: { id?: string; kind?: string }; streams?: readonly ApprovedGrantStream[]; }; grant_id?: string; + grant_storage_binding?: { connector_id?: string }; inactive_reason?: string; + pdpp?: { + grant_id?: string; + source?: { id?: string; kind?: string }; + }; pdpp_token_kind?: string; subject_id?: string; } @@ -151,6 +175,32 @@ interface SpotifyManifest { streams: SpotifyManifestStream[]; } +function sourceIdForConnectorId(connectorId: string): string { + return connectorId.includes("://") ? connectorId : `https://registry.pdpp.dev/connectors/${connectorId}`; +} + +async function seedDefaultGrantInstance(connectorId: string, ownerSubjectId: string): Promise<void> { + const store = createRequestConnectorInstanceStore(); + const connectorKey = canonicalConnectorKey(connectorId) ?? connectorId; + const connectorInstanceId = makeDefaultAccountConnectorInstanceId(ownerSubjectId, connectorKey); + if (await store.get(connectorInstanceId)) { + return; + } + const now = new Date().toISOString(); + await store.upsert({ + connectorId: connectorKey, + connectorInstanceId, + createdAt: now, + displayName: "Spotify", + ownerSubjectId, + sourceBinding: { fixture: "b3-conformance-default-account" }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); +} + /** * Issue an owner token via the device flow. Needed to seed records before * issuing a client-scoped grant. @@ -192,6 +242,7 @@ async function issueClientGrant( subjectId: string, params: IssueClientGrantParams ): Promise<ApprovedGrant> { + await seedDefaultGrantInstance(params.connector_id, subjectId); const { body: par } = await fetchJson<ParResponse>(`${asUrl}/oauth/par`, { body: JSON.stringify({ authorization_details: [ @@ -199,23 +250,34 @@ async function issueClientGrant( access_mode: params.access_mode, purpose_code: params.purpose_code, purpose_description: params.purpose_description, - source: { id: params.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(params.connector_id), kind: "connector" }, streams: params.streams, type: "https://pdpp.dev/data-access", }, ], client_id: params.client_id, }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); assert.ok(par, "PAR response should return a body"); + const { body: review, status: reviewStatus } = await fetchJson<{ approval_review_revision?: unknown }>( + `${asUrl}/consent/review`, + { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: subjectId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + } + ); + assert.equal(reviewStatus, 200, JSON.stringify(review)); + assert.ok(review, "consent review returns a body"); + assert.equal(typeof review.approval_review_revision, "string", "consent review returns a revision"); const { body: approved } = await fetchJson<ApprovedGrant>(`${asUrl}/consent/approve`, { body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, request_uri: par.request_uri, - subject_id: subjectId, }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); assert.ok(approved, "consent/approve should return a body"); @@ -267,6 +329,7 @@ async function withHarness( dbPath: ":memory:", quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, })) as TestServer; const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -300,6 +363,42 @@ async function withHarness( // ─── B3.1 — active client token introspection shape ───────────────────────── +test("introspection: confidential caller credentials are mandatory (B3)", async () => { + await withHarness(async ({ asUrl }) => { + const authorizations = [ + undefined, + basicIntrospectionAuthorization({ + clientId: TEST_RS_INTROSPECTION_CREDENTIALS.clientId, + clientSecret: "wrong-secret", + }), + ]; + const responses = await Promise.all( + authorizations.map((authorization) => + fetch(`${asUrl}/introspect`, { + body: new URLSearchParams({ token: "not-relevant" }).toString(), + headers: { + ...(authorization ? { Authorization: authorization } : {}), + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }) + ) + ); + const results = await Promise.all( + responses.map(async (response) => ({ + body: (await response.json()) as ErrorBody, + status: response.status, + wwwAuthenticate: response.headers.get("www-authenticate"), + })) + ); + for (const { body, status, wwwAuthenticate } of results) { + assert.equal(status, 401); + assert.equal(wwwAuthenticate, 'Basic realm="introspection"'); + assert.equal(typeof body.error === "object" ? body.error?.code : body.error, "context.authentication_failed"); + } + }); +}); + test("introspection: active client token returns documented fields (B3)", async () => { await withHarness(async ({ asUrl, rsUrl, connectorId }) => { const ownerToken = await issueOwnerToken(asUrl, "b3_introspect_owner"); @@ -318,7 +417,7 @@ test("introspection: active client token returns documented fields (B3)", async const { status, body } = await fetchJson<IntrospectionBody>(`${asUrl}/introspect`, { body: JSON.stringify({ token: approved.token }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); @@ -332,23 +431,29 @@ test("introspection: active client token returns documented fields (B3)", async assert.equal(body.grant_id, approved.grant.grant_id, "grant_id matches issued grant"); assert.equal(body.client_id, "longview", "client_id matches requester"); - // grant object must be present and contain the source + streams - assert.ok(body.grant, "grant object present"); - assert.equal(body.grant.grant_id, approved.grant.grant_id, "grant.grant_id matches"); - assert.equal(body.grant.source?.kind, "connector", "grant.source.kind = connector"); - assert.equal(body.grant.access_mode, "continuous", "grant.access_mode matches"); - assert.ok(Array.isArray(body.grant.streams), "grant.streams is an array"); - const firstStream = body.grant.streams?.[0]; - assert.ok(firstStream, "introspected grant has at least one stream"); + // Public introspection projects the grant into RFC 9396 authorization_details + // plus PDPP context instead of exposing the AS-internal grant object. + assert.ok(Array.isArray(body.authorization_details), "authorization_details is an array"); + const [detail] = body.authorization_details; + assert.ok(detail, "introspected grant has an authorization detail"); + assert.equal(detail.type, "https://pdpp.dev/data-access", "authorization detail type is PDPP data access"); + assert.equal(detail.source?.kind, "connector", "authorization detail source.kind = connector"); + assert.equal(detail.access_mode, "continuous", "authorization detail access_mode matches"); + assert.ok(Array.isArray(detail.streams), "authorization detail streams is an array"); + const firstStream = detail.streams?.[0]; + assert.ok(firstStream, "introspected authorization detail has at least one stream"); assert.equal(firstStream.name, "top_artists", "stream name preserved"); + assert.equal(body.pdpp?.grant_id, approved.grant.grant_id, "pdpp.grant_id matches"); + assert.equal(body.pdpp?.source?.id, detail.source?.id, "pdpp.source matches authorization detail source"); - // exp: either null or a number - assert.ok(body.exp === null || typeof body.exp === "number", "exp is null or numeric Unix timestamp"); + // RFC 7662 omits exp when the token has no finite expiry. + assert.ok(body.exp === undefined || typeof body.exp === "number", "exp is omitted or a numeric Unix timestamp"); - // grant_storage_binding MUST NOT appear in the public response (operation redacts it) - assert.ok( - !("grant_storage_binding" in body), - "grant_storage_binding must not appear in public introspection response" + // The confidential RS caller needs the physical binding to resolve the approved source. + assert.equal( + body.grant_storage_binding?.connector_id, + canonicalConnectorKey(detail.source?.id ?? null), + "storage binding matches approved source" ); }); }); @@ -356,7 +461,11 @@ test("introspection: active client token returns documented fields (B3)", async // ─── B3.2 — inactive token: grant_revoked ─────────────────────────────────── test("introspection: revoked grant returns active=false with inactive_reason (B3)", async () => { - await withHarness(async ({ asUrl, connectorId }) => { + await withHarness(async ({ asUrl, connectorId, rsUrl }) => { + const ownerToken = await issueOwnerToken(asUrl, "b3_revoke_owner"); + await seedStream(rsUrl, ownerToken, connectorId, "top_artists", [ + { id: "revoke_1", name: "Revocation fixture", source_updated_at: "2026-01-01T00:00:00Z" }, + ]); const approved = await issueClientGrant(asUrl, "b3_revoke_owner", { access_mode: "continuous", client_id: "longview", @@ -380,7 +489,7 @@ test("introspection: revoked grant returns active=false with inactive_reason (B3 const { status, body } = await fetchJson<IntrospectionBody>(`${asUrl}/introspect`, { body: JSON.stringify({ token: approved.token }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); @@ -402,7 +511,7 @@ test("introspection: missing token returns 400 invalid_request (B3)", async () = await withHarness(async ({ asUrl }) => { const { status, body } = await fetchJson<ErrorBody>(`${asUrl}/introspect`, { body: JSON.stringify({}), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); @@ -442,15 +551,15 @@ test("resources[] round-trip: grant contains resources, RS enforces them (B3)", ], }); - // 1. Introspection reflects resources[] in the grant object + // 1. Introspection reflects resources[] in authorization_details. const { body: introBody } = await fetchJson<IntrospectionBody>(`${asUrl}/introspect`, { body: JSON.stringify({ token: approved.token }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); assert.ok(introBody, "introspect should return a body"); assert.equal(introBody.active, true, "token is active"); - const introspectedStream = introBody.grant?.streams?.[0]; + const introspectedStream = introBody.authorization_details?.[0]?.streams?.[0]; assert.ok(introspectedStream, "stream present in introspected grant"); assert.deepEqual( introspectedStream.resources, diff --git a/reference-implementation/test/b4-blob-fetch-conformance.test.ts b/reference-implementation/test/b4-blob-fetch-conformance.test.ts index ba9884f1b..9c7209868 100644 --- a/reference-implementation/test/b4-blob-fetch-conformance.test.ts +++ b/reference-implementation/test/b4-blob-fetch-conformance.test.ts @@ -27,7 +27,11 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; +import { makeDefaultAccountConnectorInstanceId } from "../server/stores/connector-instance-store.ts"; +import { TEST_INTROSPECTION_SERVER_OPTS } from "./helpers/introspection-test-credentials.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const POLYFILL_MANIFESTS_DIR = join(__dirname, "..", "..", "packages", "polyfill-connectors", "manifests"); @@ -191,6 +195,32 @@ interface ClientGrantParams { streams: { name: string; fields: string[] }[]; } +function sourceIdForConnectorId(connectorId: string): string { + return connectorId.includes("://") ? connectorId : `https://registry.pdpp.dev/connectors/${connectorId}`; +} + +async function seedDefaultGrantInstance(connectorId: string, ownerSubjectId: string): Promise<void> { + const store = createRequestConnectorInstanceStore(); + const connectorKey = canonicalConnectorKey(connectorId) ?? connectorId; + const connectorInstanceId = makeDefaultAccountConnectorInstanceId(ownerSubjectId, connectorKey); + if (await store.get(connectorInstanceId)) { + return; + } + const now = new Date().toISOString(); + await store.upsert({ + connectorId: connectorKey, + connectorInstanceId, + createdAt: now, + displayName: "GroupMe", + ownerSubjectId, + sourceBinding: { fixture: "b4-conformance-default-account" }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); +} + interface ApprovedGrant { grant: { grant_id: string; access_mode: string; expires_at?: string }; token: string; @@ -200,6 +230,7 @@ interface ApprovedGrant { * Issue a grant-scoped client token via PAR + consent/approve. */ async function issueClientGrant(asUrl: string, subjectId: string, params: ClientGrantParams): Promise<ApprovedGrant> { + await seedDefaultGrantInstance(params.connector_id, subjectId); const { body: par } = await fetchJson<{ request_uri: string }>(`${asUrl}/oauth/par`, { body: JSON.stringify({ authorization_details: [ @@ -207,22 +238,32 @@ async function issueClientGrant(asUrl: string, subjectId: string, params: Client access_mode: params.access_mode, purpose_code: params.purpose_code, purpose_description: params.purpose_description, - source: { id: params.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(params.connector_id), kind: "connector" }, streams: params.streams, type: "https://pdpp.dev/data-access", }, ], client_id: params.client_id, }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); + const { body: review, status: reviewStatus } = await fetchJson<{ approval_review_revision?: unknown }>( + `${asUrl}/consent/review`, + { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: subjectId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + } + ); + assert.equal(reviewStatus, 200, JSON.stringify(review)); + assert.equal(typeof review.approval_review_revision, "string", "consent review returns a revision"); const { body: approved } = await fetchJson<ApprovedGrant>(`${asUrl}/consent/approve`, { body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, request_uri: par.request_uri, - subject_id: subjectId, }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); return approved; @@ -252,6 +293,7 @@ async function withGmailHarness(fn: (ctx: { asUrl: string; rsUrl: string; connec dbPath: ":memory:", quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, })) as TestServer; const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; diff --git a/reference-implementation/test/b6-single-use-consumption-conformance.test.ts b/reference-implementation/test/b6-single-use-consumption-conformance.test.ts index 9f84e7c37..953fa58cc 100644 --- a/reference-implementation/test/b6-single-use-consumption-conformance.test.ts +++ b/reference-implementation/test/b6-single-use-consumption-conformance.test.ts @@ -44,7 +44,11 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { issueToken } from "../server/auth.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; +import { makeDefaultAccountConnectorInstanceId } from "../server/stores/connector-instance-store.ts"; +import { TEST_INTROSPECTION_SERVER_OPTS } from "./helpers/introspection-test-credentials.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); @@ -137,7 +141,34 @@ interface ApprovedGrant { token: string; } +function sourceIdForConnectorId(connectorId: string): string { + return connectorId.includes("://") ? connectorId : `https://registry.pdpp.dev/connectors/${connectorId}`; +} + +async function seedDefaultGrantInstance(connectorId: string, ownerSubjectId: string): Promise<void> { + const store = createRequestConnectorInstanceStore(); + const connectorKey = canonicalConnectorKey(connectorId) ?? connectorId; + const connectorInstanceId = makeDefaultAccountConnectorInstanceId(ownerSubjectId, connectorKey); + if (await store.get(connectorInstanceId)) { + return; + } + const now = new Date().toISOString(); + await store.upsert({ + connectorId: connectorKey, + connectorInstanceId, + createdAt: now, + displayName: "Spotify", + ownerSubjectId, + sourceBinding: { fixture: "b6-conformance-default-account" }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); +} + async function issueClientGrant(asUrl: string, subjectId: string, params: ClientGrantParams): Promise<ApprovedGrant> { + await seedDefaultGrantInstance(params.connector_id, subjectId); const { body: par } = await fetchJson<{ request_uri: string }>(`${asUrl}/oauth/par`, { body: JSON.stringify({ authorization_details: [ @@ -145,22 +176,32 @@ async function issueClientGrant(asUrl: string, subjectId: string, params: Client access_mode: params.access_mode, purpose_code: params.purpose_code, purpose_description: params.purpose_description, - source: { id: params.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(params.connector_id), kind: "connector" }, streams: params.streams, type: "https://pdpp.dev/data-access", }, ], client_id: params.client_id, }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); + const { body: review, status: reviewStatus } = await fetchJson<{ approval_review_revision?: unknown }>( + `${asUrl}/consent/review`, + { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: subjectId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + } + ); + assert.equal(reviewStatus, 200, JSON.stringify(review)); + assert.equal(typeof review.approval_review_revision, "string", "consent review returns a revision"); const { body: approved } = await fetchJson<ApprovedGrant>(`${asUrl}/consent/approve`, { body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, request_uri: par.request_uri, - subject_id: subjectId, }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); return approved; @@ -217,6 +258,7 @@ async function withHarness(fn: (ctx: { asUrl: string; rsUrl: string; connectorId dbPath: ":memory:", quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, })) as TestServer; const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -318,7 +360,7 @@ test("single_use: second token issuance is rejected with grant_consumed (B6)", a // HTTP 403. This is the consumption enforcement, not a generic error. await assert.rejects( () => - issueToken(approved.grant.grant_id, "b6_reissue_owner", "longview", null, { + issueToken(approved.grant.grant_id, "b6_reissue_owner", "longview", approved.grant.expires_at ?? null, { source: "b6_second_issuance", }), (err: unknown) => { diff --git a/reference-implementation/test/batch-consent-parent-package-linkage.test.ts b/reference-implementation/test/batch-consent-parent-package-linkage.test.ts index c41cb7267..18fa33be9 100644 --- a/reference-implementation/test/batch-consent-parent-package-linkage.test.ts +++ b/reference-implementation/test/batch-consent-parent-package-linkage.test.ts @@ -30,8 +30,11 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { getCumulativeClientAccessForPackage, revokeGrant } from "../server/auth.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { getDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; +import { makeDefaultAccountConnectorInstanceId } from "../server/stores/connector-instance-store.ts"; const REGEXP_1 = /access_token|refresh_token|"token"|token_hash/; const REGEXP_2 = /does not exist/; @@ -73,6 +76,25 @@ async function registerManifest(asUrl: string, manifest: ConnectorManifest) { assert.ok(resp.status < 400, `connector registration for ${manifest.connector_id} should succeed`); } +async function seedOwnerConnectorInstance(manifest: ConnectorManifest): Promise<void> { + const connectorKey = canonicalConnectorKey(manifest.connector_id); + assert.ok(connectorKey, `expected a canonical connector key for ${manifest.connector_id}`); + const connectorInstanceId = makeDefaultAccountConnectorInstanceId("owner_local", connectorKey); + const now = new Date().toISOString(); + await createRequestConnectorInstanceStore().upsert({ + connectorId: connectorKey, + connectorInstanceId, + createdAt: now, + displayName: `${connectorKey} test account`, + ownerSubjectId: "owner_local", + sourceBinding: { fixture: "batch-consent-parent-package-linkage" }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); +} + interface HarnessContext { asUrl: string; github: ConnectorManifest; @@ -90,6 +112,7 @@ async function withHarness(fn: (ctx: HarnessContext) => Promise<void>) { for (const manifest of [spotify, reddit, github]) { // biome-ignore lint/performance/noAwaitInLoops: Sequential test setup and assertion order is intentional. await registerManifest(asUrl, manifest); + await seedOwnerConnectorInstance(manifest); } await fn({ asUrl, github, reddit, spotify }); } finally { @@ -166,28 +189,68 @@ interface ApproveResult { status: number; } -async function approve(asUrl: string, body: Record<string, unknown>): Promise<ApproveResult> { - const resp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - return { body: (await resp.json().catch(() => null)) as ApproveResponseBody | null, status: resp.status }; -} - -// biome-ignore lint/suspicious/useAwait: Async callback preserves the dependency contract and rejection timing. async function approveBatch( asUrl: string, requestUri: string | undefined, approvedIndexes: number[], extra: Record<string, unknown> = {} ): Promise<ApproveResult> { - return approve(asUrl, { - approved_source_indexes: approvedIndexes, - request_uri: requestUri, - subject_id: "owner_local", - ...extra, + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ + approved_source_indexes: approvedIndexes, + request_uri: requestUri, + ...extra, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewBody = (await reviewResp.json().catch(() => null)) as + | (ApproveResponseBody & { + approval_review?: unknown; + approval_review_revision?: unknown; + }) + | null; + if (reviewResp.status !== 200) { + return { body: reviewBody, status: reviewResp.status }; + } + assert.ok(reviewBody?.approval_review && typeof reviewBody.approval_review === "object"); + assert.equal(typeof reviewBody.approval_review_revision, "string"); + const resp = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: reviewBody.approval_review_revision, + confirm_reviewed_decision: "1", + request_uri: requestUri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + return { body: (await resp.json().catch(() => null)) as ApproveResponseBody | null, status: resp.status }; +} + +async function approveSingle(asUrl: string, requestUri: string | undefined, subjectId: string): Promise<ApproveResult> { + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewBody = (await reviewResp.json().catch(() => null)) as + | (ApproveResponseBody & { + approval_review?: unknown; + approval_review_revision?: unknown; + }) + | null; + assert.equal(reviewResp.status, 200, JSON.stringify(reviewBody)); + assert.ok(reviewBody?.approval_review && typeof reviewBody.approval_review === "object"); + assert.equal(typeof reviewBody?.approval_review_revision, "string"); + const resp = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: reviewBody?.approval_review_revision, + request_uri: requestUri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", }); + return { body: (await resp.json().catch(() => null)) as ApproveResponseBody | null, status: resp.status }; } /** Narrows a `{status, body}` result's body from `T | null` to `T`, failing the assertion if null. */ @@ -369,7 +432,7 @@ test("parent linkage: revoking one child grant updates the cumulative active cou const rootPackageId = unwrapBody(root).package_id; const rootGrant = unwrapBody(root).grant; assert.ok(rootGrant?.child_grants); - const spotifyChild = rootGrant.child_grants.find((c) => c.source.id === "spotify"); + const spotifyChild = rootGrant.child_grants.find((c) => c.source.id === spotify.connector_id); assert.ok(spotifyChild, "root grant must include a spotify child grant"); const second = await par( @@ -473,10 +536,7 @@ test("parent linkage: a single-entry request without parent_package_id stays on const resp = await par(asUrl, [detail({ id: github.connector_id, kind: "connector" }, [{ name: "repositories" }])]); assert.equal(resp.status, 201); - const approved = await approve(asUrl, { - request_uri: unwrapBody(resp).request_uri, - subject_id: "owner_local", - }); + const approved = await approveSingle(asUrl, unwrapBody(resp).request_uri, "owner_local"); assert.equal(approved.status, 200); const approvedGrant = unwrapBody(approved).grant; assert.ok(approvedGrant); diff --git a/reference-implementation/test/batch-consent-per-source-gate.test.ts b/reference-implementation/test/batch-consent-per-source-gate.test.ts index ced413c46..1b883aa56 100644 --- a/reference-implementation/test/batch-consent-per-source-gate.test.ts +++ b/reference-implementation/test/batch-consent-per-source-gate.test.ts @@ -6,9 +6,17 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { getGrantPackageIdForGrant, listGrantPackagesForOwner, parsePendingConsentRequestUri } from "../server/auth.ts"; +import { + approveGrant, + denyGrant, + getGrantPackageIdForGrant, + listGrantPackagesForOwner, + parsePendingConsentRequestUri, + revokeGrantPackage, +} from "../server/auth.ts"; import { getDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; const REGEXP_1 = /Confirm each source/; const REGEXP_2 = /Reference-experimental batch consent/; @@ -18,7 +26,7 @@ const REGEXP_5 = /I confirm allowing all/; const REGEXP_6 = /Approve-all is not available/; const REGEXP_7 = /sensitive_no_time_bound/; const REGEXP_8 = /three_or_more_sensitive_sources/; -const REGEXP_9 = /requires a re-asserting confirmation/; +const REGEXP_9 = /requires (?:a re-asserting )?confirmation/; const REGEXP_10 = /out-of-range/; const REGEXP_11 = /Broad setup/; const REGEXP_12 = /reference warning threshold/; @@ -32,12 +40,17 @@ const REGEXP_19 = /widening is forbidden/; const REGEXP_20 = /not in the staged field set/; const REGEXP_21 = /earlier than the staged bound/; const REGEXP_22 = /not a valid ISO-8601 instant/; -const REGEXP_23 = /no field projection/; const REGEXP_24 = /not in the approved set/; const REGEXP_25 = /Narrow this source/; const REGEXP_26 = /name="narrow_streams_0"/; const REGEXP_27 = /name="narrow_fields_0__/; const REGEXP_28 = /name="narrow_since_0__/; +const REGEXP_29 = /Client-authored claims/; +const CONSENT_EXCHANGE_CODE_RE = /cex_[0-9a-f]{64}/; +const LEGACY_PROJECTION_REVISION_RE = /^reference\.legacy-connector-projection\.v1:sha256:[0-9a-f]{64}$/; +const PACKAGE_ID_RE = /gpkg_[a-zA-Z0-9]+/; +const SPOTIFY_BATCH_COMMITMENT = "Only use Spotify listening history for playlist suggestions."; +const REDDIT_BATCH_COMMITMENT = "Only use Reddit posts for community summaries."; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); @@ -53,6 +66,35 @@ function countRows(sql: string): number { return row.n; } +function countConsentEvents(deviceCode: string, eventType: string): number { + return ( + getDb() + .prepare( + "SELECT COUNT(*) AS count FROM spine_events WHERE object_id = ? AND object_type = 'pending_consent' AND event_type = ?" + ) + .get(deviceCode, eventType) as { count: number } + ).count; +} + +function createDecisionPause(): { hook: () => Promise<void>; paused: Promise<void>; release: () => void } { + let markPaused: () => void = () => undefined; + let release: () => void = () => undefined; + const paused = new Promise<void>((resolve) => { + markPaused = resolve; + }); + const resumed = new Promise<void>((resolve) => { + release = resolve; + }); + return { + hook: async () => { + markPaused(); + await resumed; + }, + paused, + release, + }; +} + function requirePackageId(body: GateResponseBody): string { assert.ok(typeof body.package_id === "string", "approval response includes package_id"); return body.package_id; @@ -99,6 +141,21 @@ async function withHarness(fn: (ctx: HarnessContext) => Promise<void>) { method: "POST", }); assert.ok(resp.status < 400, `connector registration for ${manifest.connector_id} should succeed`); + const connectorId = new URL(manifest.connector_id).pathname.split("/").filter(Boolean).at(-1); + assert.ok(connectorId); + const now = new Date().toISOString(); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: `cin_batch_${connectorId}`, + createdAt: now, + displayName: `${connectorId} batch fixture`, + ownerSubjectId: "owner_local", + sourceBinding: { fixture: connectorId }, + sourceBindingKey: `batch:${connectorId}`, + sourceKind: "manual", + status: "active", + updatedAt: now, + }); } await fn({ asUrl, github, reddit, spotify }); } finally { @@ -176,10 +233,57 @@ async function consentPage(asUrl: string, requestUri: string): Promise<{ status: return { html: await resp.text(), status: resp.status }; } -async function approve(asUrl: string, requestBody: Record<string, unknown>): Promise<GateResult> { +function sourceCardHtml(html: string, sourceIndex: number): string { + const start = html.indexOf(`aria-label="Source ${sourceIndex}"`); + assert.notEqual(start, -1, `expected source ${sourceIndex} card`); + const next = html.indexOf(`aria-label="Source ${sourceIndex + 1}"`, start + 1); + return next === -1 ? html.slice(start) : html.slice(start, next); +} + +async function approve( + asUrl: string, + requestBody: Record<string, unknown>, + options: { confirmReviewedDecision?: boolean } = {} +): Promise<GateResult> { + // The review artifact owns the complete batch decision. The final approval + // only re-asserts that artifact by revision; source choices must not be + // accepted again at the approval boundary. + const reviewBody = { ...requestBody }; + if ( + reviewBody.approved_source_indexes === undefined && + reviewBody.source_narrowing === undefined && + reviewBody.confirm_approve_all === undefined + ) { + reviewBody.confirm_approve_all = true; + } + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify(reviewBody), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewResult = { + body: (await reviewResp.json().catch(() => null)) as GateResponseBody | null, + status: reviewResp.status, + }; + if (reviewResp.status !== 200) { + return reviewResult; + } + const reviewedBody = reviewResult.body as GateResponseBody & { + approval_review?: unknown; + approval_review_revision?: unknown; + }; + assert.ok(reviewedBody.approval_review && typeof reviewedBody.approval_review === "object"); + assert.equal(typeof reviewedBody.approval_review_revision, "string"); + const finalBody: Record<string, unknown> = { + approval_review_revision: reviewedBody.approval_review_revision, + request_uri: requestBody.request_uri, + }; + if (options.confirmReviewedDecision !== false) { + finalBody.confirm_reviewed_decision = "1"; + } const resp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify(requestBody), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify(finalBody), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); return { body: (await resp.json().catch(() => null)) as GateResponseBody | null, status: resp.status }; @@ -193,14 +297,64 @@ async function approveForm(asUrl: string, fields: Record<string, unknown>): Prom params.append(key, String(item)); } } - const resp = await fetch(`${asUrl}/consent/approve`, { + if (!(params.has("approved_source_indexes") || params.has("source_narrowing") || params.has("confirm_approve_all"))) { + params.set("confirm_approve_all", "1"); + } + const reviewResp = await fetch(`${asUrl}/consent/review`, { body: params.toString(), headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", }); + const reviewResult = { + body: (await reviewResp.json().catch(() => null)) as GateResponseBody | null, + status: reviewResp.status, + }; + if (reviewResp.status !== 200) { + return reviewResult; + } + const reviewedBody = reviewResult.body as GateResponseBody & { + approval_review?: unknown; + approval_review_revision?: unknown; + }; + assert.ok(reviewedBody.approval_review && typeof reviewedBody.approval_review === "object"); + assert.equal(typeof reviewedBody.approval_review_revision, "string"); + const finalParams = new URLSearchParams({ + approval_review_revision: reviewedBody.approval_review_revision as string, + confirm_reviewed_decision: "1", + request_uri: String(fields.request_uri ?? ""), + }); + const resp = await fetch(`${asUrl}/consent/approve`, { + body: finalParams.toString(), + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); return { body: (await resp.json().catch(() => null)) as GateResponseBody | null, status: resp.status }; } +async function approveBatchHtml(asUrl: string, requestUri: string, approvedSourceIndexes: number[]): Promise<Response> { + const review = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ + approved_source_indexes: approvedSourceIndexes, + request_uri: requestUri, + subject_id: "owner_local", + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, await review.clone().text()); + const reviewed = (await review.json()) as { approval_review_revision?: unknown }; + assert.equal(typeof reviewed.approval_review_revision, "string"); + return fetch(`${asUrl}/consent/approve`, { + body: new URLSearchParams({ + approval_review_revision: reviewed.approval_review_revision as string, + confirm_reviewed_decision: "1", + request_uri: requestUri, + }).toString(), + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); +} + /** Narrows a `{status, body}` result's body from `T | null` to `T`, failing the assertion if null. */ function unwrapBody<T>(result: { status: number; body: T | null }): T { assert.ok(result.body, `expected a response body (status ${result.status})`); @@ -221,6 +375,68 @@ function issuedGrant(result: GateResult): IssuedGrant { return grant as IssuedGrant; } +test("batch consent terminal decision is exclusive across approval and denial", async () => { + await withHarness(async ({ asUrl, spotify, reddit }) => { + const body = unwrapBody( + await par(asUrl, [ + detail({ id: spotify.connector_id, kind: "connector" }, [{ name: "top_artists" }]), + detail({ id: reddit.connector_id, kind: "connector" }, [{ name: "posts" }]), + ]) + ); + const code = parsePendingConsentRequestUri(body.request_uri); + assert.ok(code); + const review = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ + approved_source_indexes: [0, 1], + request_uri: body.request_uri, + subject_id: "owner_local", + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200); + const revision = ((await review.json()) as { approval_review_revision: string }).approval_review_revision; + const pause = createDecisionPause(); + const denial = denyGrant(code, { beforeCasHook: pause.hook }); + await pause.paused; + const approval = await approveGrant(code, "owner_local", { approval_review_revision: revision }); + pause.release(); + await assert.rejects( + denial, + (error: unknown) => error instanceof Error && "code" in error && error.code === "approval_conflict" + ); + assert.equal(typeof approval.token, "string"); + assert.equal(countConsentEvents(code, "consent.denied"), 0); + assert.equal(countRows("SELECT COUNT(*) AS n FROM grant_packages"), 1); + + const body2 = unwrapBody( + await par(asUrl, [ + detail({ id: spotify.connector_id, kind: "connector" }, [{ name: "top_artists" }]), + detail({ id: reddit.connector_id, kind: "connector" }, [{ name: "posts" }]), + ]) + ); + const code2 = parsePendingConsentRequestUri(body2.request_uri); + assert.ok(code2); + const review2 = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ + approved_source_indexes: [0, 1], + request_uri: body2.request_uri, + subject_id: "owner_local", + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const revision2 = ((await review2.json()) as { approval_review_revision: string }).approval_review_revision; + assert.equal(await denyGrant(code2), true); + await assert.rejects( + approveGrant(code2, "owner_local", { approval_review_revision: revision2 }), + (error: unknown) => error instanceof Error && "code" in error && error.code === "approval_conflict" + ); + assert.equal(countConsentEvents(code2, "consent.denied"), 1); + assert.equal(countRows("SELECT COUNT(*) AS n FROM grant_packages"), 1); + }); +}); + test("batch consent gate: page defaults to per-source confirmation and suppresses approve-all for continuous all-streams", async () => { await withHarness(async ({ asUrl, spotify, reddit }) => { const parResult = await par(asUrl, [ @@ -244,6 +460,33 @@ test("batch consent gate: page defaults to per-source confirmation and suppresse }); }); +test("batch consent gate: top-level client_claims render under the matching source cards", async () => { + await withHarness(async ({ asUrl, spotify, reddit }) => { + const body = unwrapBody( + await par(asUrl, [ + detail({ id: spotify.connector_id, kind: "connector" }, [{ name: "top_artists" }], { + client_claims: { commitments: [SPOTIFY_BATCH_COMMITMENT] }, + }), + detail({ id: reddit.connector_id, kind: "connector" }, [{ name: "posts" }], { + client_claims: { commitments: [REDDIT_BATCH_COMMITMENT] }, + }), + ]) + ); + + assert.ok(body.request_uri); + const { status, html } = await consentPage(asUrl, body.request_uri); + assert.equal(status, 200); + const spotifyCard = sourceCardHtml(html, 1); + const redditCard = sourceCardHtml(html, 2); + assert.match(spotifyCard, REGEXP_29); + assert.match(redditCard, REGEXP_29); + assert.ok(spotifyCard.includes(SPOTIFY_BATCH_COMMITMENT)); + assert.ok(!spotifyCard.includes(REDDIT_BATCH_COMMITMENT)); + assert.ok(redditCard.includes(REDDIT_BATCH_COMMITMENT)); + assert.ok(!redditCard.includes(SPOTIFY_BATCH_COMMITMENT)); + }); +}); + test("batch consent gate: suppressed approve-all cannot silently approve every source", async () => { await withHarness(async ({ asUrl, spotify, reddit }) => { const body = unwrapBody( @@ -324,7 +567,7 @@ test("batch consent gate: explicit per-source indexes issue only the selected ch assert.equal(issuedGrant(approved).child_grants.length, 1); const [firstChild] = issuedGrant(approved).child_grants; assert.ok(firstChild); - assert.equal(firstChild.source.id, "reddit"); + assert.equal(firstChild.source.id, reddit.connector_id); const db = getDb(); assert.equal((db.prepare("SELECT COUNT(*) AS n FROM grants").get() as { n: number }).n, 1); @@ -355,7 +598,7 @@ test("batch consent gate: approved sources become independent child grants under issuedGrant(approved) .child_grants.map((child) => child.source.id) .sort(), - ["reddit", "spotify"] + [reddit.connector_id, spotify.connector_id] ); const db = getDb(); @@ -383,6 +626,61 @@ test("batch consent gate: approved sources become independent child grants under }); }); +test("batch consent gate: HTML approval hands off the package token durably", async () => { + await withHarness(async ({ asUrl, spotify, reddit }) => { + const staged = unwrapBody( + await par(asUrl, [ + detail({ id: spotify.connector_id, kind: "connector" }, [{ name: "top_artists" }]), + detail({ id: reddit.connector_id, kind: "connector" }, [{ name: "posts" }]), + ]) + ); + const approval = await approveBatchHtml(asUrl, staged.request_uri || "", [0, 1]); + assert.equal(approval.status, 200); + const code = (await approval.text()).match(CONSENT_EXCHANGE_CODE_RE)?.[0]; + assert.ok(code); + const exchange = await fetch(`${asUrl}/consent/exchange`, { + body: JSON.stringify({ code }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(exchange.status, 200); + const result = (await exchange.json()) as { + grant: { child_grants?: unknown[]; package?: boolean }; + package_id?: string; + token?: string; + }; + assert.ok(result.package_id?.startsWith("gpkg_")); + assert.equal(result.grant.package, true); + assert.equal(result.grant.child_grants?.length, 2); + assert.ok(result.token); + }); +}); + +test("batch consent gate: a revoked package is not delivered by a stored exchange code", async () => { + await withHarness(async ({ asUrl, spotify, reddit }) => { + const staged = unwrapBody( + await par(asUrl, [ + detail({ id: spotify.connector_id, kind: "connector" }, [{ name: "top_artists" }]), + detail({ id: reddit.connector_id, kind: "connector" }, [{ name: "posts" }]), + ]) + ); + const approval = await approveBatchHtml(asUrl, staged.request_uri || "", [0, 1]); + const html = await approval.text(); + const code = html.match(CONSENT_EXCHANGE_CODE_RE)?.[0]; + const packageId = html.match(PACKAGE_ID_RE)?.[0]; + assert.ok(code); + assert.ok(packageId); + await revokeGrantPackage(packageId); + const exchange = await fetch(`${asUrl}/consent/exchange`, { + body: JSON.stringify({ code }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(exchange.status, 404); + assert.equal((await exchange.text()).includes("tok_"), false); + }); +}); + test("batch consent gate: low-risk approve-all requires re-asserting confirmation", async () => { await withHarness(async ({ asUrl, spotify, reddit }) => { const entries = [ @@ -391,10 +689,14 @@ test("batch consent gate: low-risk approve-all requires re-asserting confirmatio ]; const first = await par(asUrl, entries); - const missingConfirmation = await approve(asUrl, { - request_uri: unwrapBody(first).request_uri, - subject_id: "owner_local", - }); + const missingConfirmation = await approve( + asUrl, + { + request_uri: unwrapBody(first).request_uri, + subject_id: "owner_local", + }, + { confirmReviewedDecision: false } + ); assert.equal(missingConfirmation.status, 400); assert.match(unwrapBody(missingConfirmation).error?.message ?? "", REGEXP_9); @@ -449,13 +751,57 @@ test("batch consent gate: staged batch remains source-bounded in storage", async }; const stored = JSON.parse(row.params_json) as { request_kind: string; - entries: { source_binding: { id: string } }[]; + entries: { + source_binding: { id: string }; + source_declaration_snapshot: { + declaration: { + declaration_version: string; + extensions?: Record<string, { connector?: { id?: string; version?: string } }>; + publisher: { id: string }; + source: { id: string; kind: string }; + streams: { name: string }[]; + }; + declaration_version: string; + snapshot_version: string; + source: { id: string; kind: string }; + }; + }[]; }; assert.equal(stored.request_kind, "pdpp_selection_request_batch"); assert.deepEqual( stored.entries.map((entry) => entry.source_binding.id), - ["spotify", "reddit"] + [spotify.connector_id, reddit.connector_id] + ); + const [spotifySnapshot, redditSnapshot] = stored.entries.map((entry) => entry.source_declaration_snapshot); + assert.ok(spotifySnapshot); + assert.ok(redditSnapshot); + for (const snapshot of [spotifySnapshot, redditSnapshot]) { + assert.match(snapshot.declaration_version, LEGACY_PROJECTION_REVISION_RE); + assert.equal(snapshot.snapshot_version, "reference.source-declaration-snapshot.v1"); + assert.equal(snapshot.declaration.declaration_version, snapshot.declaration_version); + } + assert.deepEqual(spotifySnapshot.source, { id: spotify.connector_id, kind: "connector" }); + assert.deepEqual(redditSnapshot.source, { id: reddit.connector_id, kind: "connector" }); + const [firstStoredEntry] = stored.entries; + assert.ok(firstStoredEntry); + assert.deepEqual(firstStoredEntry.source_declaration_snapshot.declaration.source, { + id: spotify.connector_id, + kind: "connector", + }); + assert.deepEqual(firstStoredEntry.source_declaration_snapshot.declaration.publisher, { + id: "https://pdpp.dev/reference-implementation", + }); + assert.equal( + firstStoredEntry.source_declaration_snapshot.declaration.declaration_version, + firstStoredEntry.source_declaration_snapshot.declaration_version ); + assert.equal("connector_id" in firstStoredEntry.source_declaration_snapshot.declaration, false); + assert.equal("version" in firstStoredEntry.source_declaration_snapshot.declaration, false); + const collectionExtension = + firstStoredEntry.source_declaration_snapshot.declaration.extensions?.["https://pdpp.org/profile/collection"]; + assert.ok(collectionExtension); + assert.deepEqual(collectionExtension.connector, { id: spotify.manifest_uri, version: spotify.version }); + assert.equal(firstStoredEntry.source_declaration_snapshot.declaration.streams[0]?.name, "top_artists"); }); }); @@ -513,7 +859,7 @@ test("batch consent gate: over-soft-cap requests are flagged with affected sourc assert.equal(stored.over_soft_cap, true); assert.deepEqual( stored.over_cap_sources.map((source) => source.id), - ["reddit"] + [reddit.connector_id] ); // The ceremony flags the over-cap condition and names the affected source. @@ -557,7 +903,13 @@ test("batch consent gate: a package mixing access modes across approved sources interface StoredGrant { source?: { id: string }; - streams: { name: string; fields?: string[]; time_range?: { since: string } }[]; + streams: { + name: string; + fields?: string[]; + instance_ids?: string[]; + time_constraint?: { field: string; since?: string }; + time_range?: { since: string }; + }[]; } function childGrantStreams( @@ -598,12 +950,12 @@ test("batch consent narrowing: owner defers a source by approving a subset", asy assert.equal(issuedGrant(approved).child_grants.length, 1); const [onlySpotifyChild] = issuedGrant(approved).child_grants; assert.ok(onlySpotifyChild); - assert.equal(onlySpotifyChild.source.id, "spotify"); + assert.equal(onlySpotifyChild.source.id, spotify.connector_id); const db = getDb(); assert.equal((db.prepare("SELECT COUNT(*) AS n FROM grants").get() as { n: number }).n, 1); // No reddit grant issued from this ceremony. - assert.equal(childGrantStreams(db, requirePackageId(unwrapBody(approved)), "reddit"), null); + assert.equal(childGrantStreams(db, requirePackageId(unwrapBody(approved)), reddit.connector_id), null); }); }); @@ -631,11 +983,11 @@ test("batch consent narrowing: HTML form defers a source even when nested contro assert.equal(issuedGrant(approved).child_grants.length, 1); const [formSpotifyChild] = issuedGrant(approved).child_grants; assert.ok(formSpotifyChild); - assert.equal(formSpotifyChild.source.id, "spotify"); + assert.equal(formSpotifyChild.source.id, spotify.connector_id); const db = getDb(); assert.equal((db.prepare("SELECT COUNT(*) AS n FROM grants").get() as { n: number }).n, 1); - assert.equal(childGrantStreams(db, requirePackageId(unwrapBody(approved)), "reddit"), null); + assert.equal(childGrantStreams(db, requirePackageId(unwrapBody(approved)), reddit.connector_id), null); }); }); @@ -658,14 +1010,14 @@ test("batch consent narrowing: owner reduces a wildcard source to a single strea assert.equal(approved.status, 200); const db = getDb(); - const spotifyStreams = childGrantStreams(db, requirePackageId(unwrapBody(approved)), "spotify"); + const spotifyStreams = childGrantStreams(db, requirePackageId(unwrapBody(approved)), spotify.connector_id); assert.ok(spotifyStreams); assert.deepEqual( spotifyStreams.map((s) => s.name), ["top_artists"] ); // reddit untouched. - const redditStreams = childGrantStreams(db, requirePackageId(unwrapBody(approved)), "reddit"); + const redditStreams = childGrantStreams(db, requirePackageId(unwrapBody(approved)), reddit.connector_id); assert.ok(redditStreams); assert.deepEqual( redditStreams.map((s) => s.name), @@ -699,7 +1051,7 @@ test("batch consent narrowing: owner reduces a stream to a subset of staged fiel assert.equal(approved.status, 200); const db = getDb(); - const streams = childGrantStreams(db, requirePackageId(unwrapBody(approved)), "spotify"); + const streams = childGrantStreams(db, requirePackageId(unwrapBody(approved)), spotify.connector_id); assert.ok(streams); assert.equal(streams.length, 1); const [fieldNarrowedStream] = streams; @@ -728,11 +1080,12 @@ test("batch consent narrowing: owner tightens an existing time bound", async () assert.equal(approved.status, 200); const db = getDb(); - const streams = childGrantStreams(db, requirePackageId(unwrapBody(approved)), "spotify"); + const streams = childGrantStreams(db, requirePackageId(unwrapBody(approved)), spotify.connector_id); assert.ok(streams); const [timeBoundStream] = streams; - assert.ok(timeBoundStream?.time_range); - assert.equal(timeBoundStream.time_range.since, "2026-03-01T00:00:00Z"); + assert.ok(timeBoundStream?.time_constraint); + assert.equal(timeBoundStream.time_constraint.field, "source_updated_at"); + assert.equal(timeBoundStream.time_constraint.since, "2026-03-01T00:00:00Z"); }); }); @@ -833,11 +1186,10 @@ test("batch consent narrowing: a malformed since value is rejected before issuin }); }); -test("batch consent narrowing: a field subset on an unprojected stream is rejected", async () => { +test("batch consent narrowing: omitted fields resolve from the snapshot and may be narrowed", async () => { await withHarness(async ({ asUrl, spotify, reddit }) => { - // spotify top_artists staged with NO field projection. A field subset - // cannot be proven narrower against an unprojected (full-record) stream, so - // the narrowing is rejected rather than silently issuing the full record. + // Omitted fields resolve to the snapshot's complete field set at staging, + // so owner narrowing has a concrete immutable baseline. const body = unwrapBody( await par(asUrl, [ detail({ id: spotify.connector_id, kind: "connector" }, [{ name: "top_artists" }]), @@ -845,16 +1197,15 @@ test("batch consent narrowing: a field subset on an unprojected stream is reject ]) ); - const rejected = await approve(asUrl, { + const approved = await approve(asUrl, { approved_source_indexes: [0, 1], request_uri: body.request_uri, source_narrowing: { 0: { fields: { top_artists: ["id"] } } }, subject_id: "owner_local", }); - assert.equal(rejected.status, 400); - assert.match(unwrapBody(rejected).error?.message ?? "", REGEXP_23); - - assert.equal(countRows("SELECT COUNT(*) AS n FROM grants"), 0); + assert.equal(approved.status, 200); + const streams = childGrantStreams(getDb(), requirePackageId(unwrapBody(approved)), spotify.connector_id); + assert.deepEqual(streams?.[0]?.fields, ["id", "name"]); }); }); diff --git a/reference-implementation/test/blob-fan-in-ambiguity.test.ts b/reference-implementation/test/blob-fan-in-ambiguity.test.ts index 335b3e816..3ef3473f1 100644 --- a/reference-implementation/test/blob-fan-in-ambiguity.test.ts +++ b/reference-implementation/test/blob-fan-in-ambiguity.test.ts @@ -11,7 +11,7 @@ * visible through more than one connection and the request did not * specify `connection_id`. * - * P2: blob reads must respect grant-scope `streams[].connection_id`. A + * P2: blob reads must respect grant-scope `streams[].instance_ids`. A * grant pinned to connection A for stream S must not expose blob * bytes reachable only from connection B for stream S. * @@ -28,7 +28,8 @@ import { OWNER_AUTH_DEFAULT_SUBJECT_ID } from "../server/owner-auth.ts"; import { ingestRecord } from "../server/records.ts"; import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; -const CONNECTOR_ID = "blob-fan-in"; +const CONNECTOR_ID = "spotify"; +const SOURCE_ID = "https://registry.pdpp.dev/connectors/spotify"; const STREAM_A = "photos"; const STREAM_B = "videos"; const INSTANCE_A = "cin_blob_account_a"; @@ -36,7 +37,7 @@ const INSTANCE_B = "cin_blob_account_b"; const baseManifest = { capabilities: { human_interaction: [] }, - connector_id: CONNECTOR_ID, + connector_id: SOURCE_ID, display_name: "Blob Fan-in Test Connector", protocol_version: "0.1.0", streams: [ @@ -64,7 +65,8 @@ const baseManifest = { required: ["id", "received_at"], type: "object", }, - selection: { fields: { mode: "explicit" } }, + selection: { fields: true, resources: false }, + semantics: "mutable_state", }, { consent_time_field: "received_at", @@ -90,7 +92,8 @@ const baseManifest = { required: ["id", "received_at"], type: "object", }, - selection: { fields: { mode: "explicit" } }, + selection: { fields: true, resources: false }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -327,9 +330,9 @@ test("GET /v1/blobs/:blob_id?connection_id=X resolves ambiguity and returns byte }); }); -// ─── P2: blob reads respect grant-scope per-stream connection_id ─────────── +// ─── P2: blob reads respect grant-scope per-stream instance_ids ─────────── -test("blob route per-stream binding resolution narrows by grant connection_id", async () => { +test("blob route per-stream binding resolution narrows by grant instance_ids", async () => { // The blob route resolves the addressable set per (binding's) stream by // calling `resolveReadRequestBindings({ grant, streamName: binding.stream })`. // When the grant pins stream A → connection X, the resolver MUST return @@ -340,12 +343,13 @@ test("blob route per-stream binding resolution narrows by grant connection_id", const { resolveReadRequestBindings } = await import("../server/records.ts"); const pinnedGrant = { streams: [ - { connection_id: INSTANCE_A, fields: ["id", "received_at"], name: STREAM_A }, - { connection_id: INSTANCE_B, fields: ["id", "received_at"], name: STREAM_B }, + { fields: ["id", "received_at"], instance_ids: [INSTANCE_A], name: STREAM_A }, + { fields: ["id", "received_at"], instance_ids: [INSTANCE_B], name: STREAM_B }, ], }; const { bindings: photosBindings } = await resolveReadRequestBindings({ grant: pinnedGrant, + ownerRead: false, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, requestParams: {}, storageBinding: { connector_id: CONNECTOR_ID }, @@ -362,6 +366,7 @@ test("blob route per-stream binding resolution narrows by grant connection_id", const { bindings: videosBindings } = await resolveReadRequestBindings({ grant: pinnedGrant, + ownerRead: false, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, requestParams: {}, storageBinding: { connector_id: CONNECTOR_ID }, @@ -424,7 +429,7 @@ interface ApproveGrantParams { connector_id: string; purpose_code: string; purpose_description: string; - streams: { name: string; fields: string[]; connection_id: string }[]; + streams: { name: string; fields: string[]; instance_ids: string[] }[]; } interface ApprovedGrant { @@ -439,7 +444,7 @@ async function approveGrant(asUrl: string, subjectId: string, params: ApproveGra access_mode: params.access_mode, purpose_code: params.purpose_code, purpose_description: params.purpose_description, - source: { id: params.connector_id, kind: "connector" }, + source: { id: SOURCE_ID, kind: "connector" }, streams: params.streams, type: "https://pdpp.dev/data-access", }, @@ -453,9 +458,21 @@ async function approveGrant(asUrl: string, subjectId: string, params: ApproveGra if (!parBody.request_uri) { throw new Error(`PAR returned no request_uri: ${JSON.stringify(parBody)}`); } - const approveResp = await fetch(`${asUrl}/consent/approve`, { + const reviewResp = await fetch(`${asUrl}/consent/review`, { body: JSON.stringify({ request_uri: parBody.request_uri, subject_id: subjectId }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const review = (await reviewResp.json()) as { approval_review_revision?: unknown }; + if (!reviewResp.ok || typeof review.approval_review_revision !== "string") { + throw new Error(`consent/review failed: ${JSON.stringify(review)}`); + } + const approveResp = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: parBody.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); const approved = (await approveResp.json()) as Partial<ApprovedGrant>; @@ -499,7 +516,7 @@ test("GET /v1/blobs/:blob_id (client mode) 404s when grant pins stream to a conn connector_id: CONNECTOR_ID, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "blob route grant-scope narrowing test", - streams: [{ connection_id: INSTANCE_A, fields: ["id", "received_at"], name: STREAM_A }], + streams: [{ fields: ["id", "received_at"], instance_ids: [INSTANCE_A], name: STREAM_A }], }); const resp = await fetch(`${rsUrl}/v1/blobs/${encodeURIComponent(blobId)}`, { @@ -515,6 +532,66 @@ test("GET /v1/blobs/:blob_id (client mode) 404s when grant pins stream to a conn }); }); +test("client blob reads fail closed for an ungranted stream and an inactive granted instance", async () => { + await issueOwnerOnlyHarness(async (server) => { + const ungrantedBlobId = "blob_sha256_ungranted_stream_006"; + const inactiveBlobId = "blob_sha256_inactive_instance_007"; + for (const [blobId, recordKey] of [ + [ungrantedBlobId, "rec-b-ungranted"], + [inactiveBlobId, "rec-b-inactive"], + ] as const) { + seedBlob({ + blobId, + connectorInstanceId: INSTANCE_B, + data: Buffer.from(blobId), + mimeType: "application/octet-stream", + recordKey, + stream: STREAM_A, + }); + // These writes share one store and must complete in fixture order. + // biome-ignore lint/performance/noAwaitInLoops: sequential fixture setup preserves deterministic state. + await ingestRecord(target(INSTANCE_B), { + data: { blob_ref: { blob_id: blobId }, id: recordKey, received_at: "2026-05-19T00:00:00.000Z" }, + emitted_at: "2026-05-19T00:00:00.000Z", + key: recordKey, + stream: STREAM_A, + }); + } + + const asUrl = `http://localhost:${server.asPort}`; + const rsUrl = `http://localhost:${server.rsPort}`; + const otherStreamGrant = await approveGrant(asUrl, OWNER_AUTH_DEFAULT_SUBJECT_ID, { + access_mode: "continuous", + client_id: "longview", + connector_id: CONNECTOR_ID, + purpose_code: "https://pdpp.dev/purpose/analytics", + purpose_description: "ungranted stream blob test", + streams: [{ fields: ["id", "received_at"], instance_ids: [INSTANCE_A], name: STREAM_B }], + }); + const ungrantedResponse = await fetch(`${rsUrl}/v1/blobs/${ungrantedBlobId}`, { + headers: { Authorization: `Bearer ${otherStreamGrant.token}` }, + }); + assert.equal(ungrantedResponse.status, 404); + + const inactiveInstanceGrant = await approveGrant(asUrl, OWNER_AUTH_DEFAULT_SUBJECT_ID, { + access_mode: "continuous", + client_id: "longview", + connector_id: CONNECTOR_ID, + purpose_code: "https://pdpp.dev/purpose/analytics", + purpose_description: "inactive instance blob test", + streams: [{ fields: ["id", "received_at"], instance_ids: [INSTANCE_A], name: STREAM_A }], + }); + await createSqliteConnectorInstanceStore().updateStatus(INSTANCE_A, { + status: "paused", + updatedAt: new Date().toISOString(), + }); + const inactiveResponse = await fetch(`${rsUrl}/v1/blobs/${inactiveBlobId}`, { + headers: { Authorization: `Bearer ${inactiveInstanceGrant.token}` }, + }); + assert.equal(inactiveResponse.status, 404); + }); +}); + // ─── PDPP-Warning: deprecated_alias_used on the 200 success path ─────────── // // P3 follow-up from `tmp/workstreams/fan-in-revision-owner-review-report.md`: diff --git a/reference-implementation/test/blob-store-route-regression.test.ts b/reference-implementation/test/blob-store-route-regression.test.ts index c21ad6e13..ed0fdda30 100644 --- a/reference-implementation/test/blob-store-route-regression.test.ts +++ b/reference-implementation/test/blob-store-route-regression.test.ts @@ -25,7 +25,9 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); @@ -126,10 +128,28 @@ async function registerConnector(asUrl: string, manifest: ConnectorManifest) { } } +async function seedActiveInstance(manifest: ConnectorManifest): Promise<void> { + const connectorId = canonicalConnectorKey(manifest.connector_id) ?? manifest.connector_id; + const now = new Date().toISOString(); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: "cin_blob_route_gmail", + createdAt: now, + displayName: "Gmail", + ownerSubjectId: "owner_local", + sourceBinding: { kind: "test_account", label: "blob-route-gmail" }, + sourceBindingKey: "blob-route-gmail", + sourceKind: "account", + status: "active", + updatedAt: now, + }); +} + test("GET /v1/blobs/:blob_id returns 404 blob_not_found for unknown blob_id", async () => { await withHarness(async ({ asUrl, rsUrl }) => { const manifest = loadGmailManifest(); await registerConnector(asUrl, manifest); + await seedActiveInstance(manifest); const ownerToken = await issueOwnerToken(asUrl); const resp = await fetch( @@ -146,6 +166,7 @@ test("GET /v1/blobs/:blob_id returns 404 when blob exists but no visible record await withHarness(async ({ asUrl, rsUrl }) => { const manifest = loadGmailManifest(); await registerConnector(asUrl, manifest); + await seedActiveInstance(manifest); const ownerToken = await issueOwnerToken(asUrl); // Upload a blob without a corresponding record. The blob row + binding @@ -185,6 +206,7 @@ test("GET /v1/blobs/:blob_id returns 200 with bytes when a visible record refere await withHarness(async ({ asUrl, rsUrl }) => { const manifest = loadGmailManifest(); await registerConnector(asUrl, manifest); + await seedActiveInstance(manifest); const ownerToken = await issueOwnerToken(asUrl); const bytes = Buffer.from("hello-world", "utf8"); diff --git a/reference-implementation/test/cli.test.ts b/reference-implementation/test/cli.test.ts index 8934c1e90..d859e45d5 100644 --- a/reference-implementation/test/cli.test.ts +++ b/reference-implementation/test/cli.test.ts @@ -11,7 +11,7 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { runConnector } from "../runtime/index.ts"; -import { parsePendingConsentRequestUri } from "../server/auth.ts"; +import { parsePendingConsentRequestUri, registerConnector } from "../server/auth.ts"; import { canonicalConnectorKey } from "../server/connector-key.ts"; import { getDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; @@ -21,10 +21,16 @@ import { admitOwnerRunConnection, makeDefaultAccountConnectorInstanceId, } from "../server/stores/connector-instance-store.ts"; +import { + TEST_INTROSPECTION_SERVER_OPTS, + TEST_RS_INTROSPECTION_CREDENTIALS, +} from "./helpers/introspection-test-credentials.ts"; + +type RuntimeConnectorManifest = NonNullable<Parameters<typeof runConnector>[0]["manifest"]>; const TOP_LEVEL_REGEX_1 = /Reference trace ID: (trc_[A-Za-z0-9]+)/; const TOP_LEVEL_REGEX_2 = /^warning: "pdpp trace show" is deprecated; use "pdpp ref trace show" instead\.$/m; -const TOP_LEVEL_REGEX_3 = /Unknown connector: missing_spotify_connector/; +const TOP_LEVEL_REGEX_3 = /Grant is malformed or no longer valid/; const TOP_LEVEL_REGEX_4 = /User code: ([A-Z0-9]+)/; const TOP_LEVEL_REGEX_5 = /Verification URI:/; const TOP_LEVEL_REGEX_6 = /User code: ([A-Z0-9]+)/; @@ -62,19 +68,17 @@ const TOP_LEVEL_REGEX_37 = /Request ID: req_/; const TOP_LEVEL_REGEX_38 = /Reference trace ID: trc_/; const TOP_LEVEL_REGEX_39 = /malformed or no longer valid/; const TOP_LEVEL_REGEX_40 = /Unknown client_id/; -const TOP_LEVEL_REGEX_41 = - /Pending consent request manifest_version '999\.0\.0' does not match current manifest version/; const TOP_LEVEL_REGEX_42 = /Access Denied/; const TOP_LEVEL_REGEX_43 = /Access Denied/; const TOP_LEVEL_REGEX_44 = /Unsupported request fields: redirect_uri, response_type/; -const TOP_LEVEL_REGEX_45 = /Stream 'saved_tracks' view and fields are mutually exclusive/; +const TOP_LEVEL_REGEX_45 = /Selection request is invalid: \/streams\/0 must NOT be valid/; const TOP_LEVEL_REGEX_46 = /Grant is malformed or no longer valid/; const TOP_LEVEL_REGEX_47 = /Request ID: (req_[A-Za-z0-9]+)/; const TOP_LEVEL_REGEX_48 = /Reference trace ID: (trc_[A-Za-z0-9]+)/; const TOP_LEVEL_REGEX_49 = /Invalid initial access token/; -const TOP_LEVEL_REGEX_50 = /source.*provider_native/; +const TOP_LEVEL_REGEX_50 = /Source kind does not match the retained declaration/; const TOP_LEVEL_REGEX_51 = /Unknown source/; -const TOP_LEVEL_REGEX_52 = /source: \{ kind/; +const TOP_LEVEL_REGEX_52 = /Selection request is invalid: .*additional properties.*source\/id must match format "uri"/s; const TOP_LEVEL_REGEX_53 = /Registered client:/; const TOP_LEVEL_REGEX_54 = /User code: ([A-Z0-9]+)/; const TOP_LEVEL_REGEX_55 = /is not scoped to stream saved_tracks/; @@ -83,27 +87,26 @@ const TOP_LEVEL_REGEX_57 = /Request ID: (req_[A-Za-z0-9]+)/; const TOP_LEVEL_REGEX_58 = /Reference trace ID: (trc_[A-Za-z0-9]+)/; const TOP_LEVEL_REGEX_59 = /view and fields are mutually exclusive/; const TOP_LEVEL_REGEX_60 = /view and fields are mutually exclusive/; -const TOP_LEVEL_REGEX_61 = /Unknown connector: missing_spotify_connector/; +const TOP_LEVEL_REGEX_61 = /Grant is malformed or no longer valid/; const TOP_LEVEL_REGEX_62 = /Request ID: (req_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_63 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; -const TOP_LEVEL_REGEX_64 = /Unknown connector: missing_spotify_connector/; -const TOP_LEVEL_REGEX_65 = /Unknown connector: missing_spotify_connector/; +const TOP_LEVEL_REGEX_64 = /Grant is malformed or no longer valid/; +const TOP_LEVEL_REGEX_65 = /Grant is malformed or no longer valid/; const TOP_LEVEL_REGEX_66 = /Request ID: (req_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_67 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; -const TOP_LEVEL_REGEX_68 = /Unknown connector: missing_spotify_connector/; +const TOP_LEVEL_REGEX_68 = /Grant is malformed or no longer valid/; const TOP_LEVEL_REGEX_69 = /Request ID: (req_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_70 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; -const TOP_LEVEL_REGEX_71 = /Unknown connector: missing_spotify_connector/; -const TOP_LEVEL_REGEX_72 = /Unknown connector: missing_spotify_connector/; +const TOP_LEVEL_REGEX_71 = /Grant is malformed or no longer valid/; +const TOP_LEVEL_REGEX_72 = /Grant is malformed or no longer valid/; const TOP_LEVEL_REGEX_73 = /Request ID: (req_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_74 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; -const TOP_LEVEL_REGEX_75 = /Filter on field 'popularity' not in grant/; -const TOP_LEVEL_REGEX_76 = /Filter on field 'popularity' not in grant/; +const TOP_LEVEL_REGEX_CLIENT_FILTER_UNSUPPORTED = + /filter\[\.\.\.\] is not supported for client-token reads in PDPP v0\.1/; +const TOP_LEVEL_REGEX_INTROSPECTION_FAILED = /Token introspection failed closed/; +const TOP_LEVEL_REGEX_INVALID_TOKEN = /Invalid or expired token/; const TOP_LEVEL_REGEX_77 = /Record not found/; const TOP_LEVEL_REGEX_78 = /Record not found/; -const TOP_LEVEL_REGEX_79 = /Stream 'recently_played' not in grant/; -const TOP_LEVEL_REGEX_80 = /Stream 'recently_played' not in grant/; -const TOP_LEVEL_REGEX_81 = /Stream 'saved_tracks' not in grant/; const TOP_LEVEL_REGEX_82 = /Record not found/; const TOP_LEVEL_REGEX_83 = /Record not found/; const TOP_LEVEL_REGEX_84 = /invalid INTERACTION.kind/; @@ -142,7 +145,7 @@ const TOP_LEVEL_REGEX_116 = /view and fields are mutually exclusive/; const TOP_LEVEL_REGEX_117 = /Request ID: (req_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_118 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_119 = /view and fields are mutually exclusive/; -const TOP_LEVEL_REGEX_120 = /Unknown connector: missing_spotify_connector/; +const TOP_LEVEL_REGEX_120 = /Unknown source: missing_spotify_connector/; const TOP_LEVEL_REGEX_121 = /Request ID: req_/; const TOP_LEVEL_REGEX_122 = /Reference trace ID: trc_qry_/; const TOP_LEVEL_REGEX_123 = /Request ID: (req_[A-Za-z0-9_]+)/; @@ -159,6 +162,9 @@ const TOP_LEVEL_REGEX_133 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_134 = /Stream 'not_a_stream' not found/; const TOP_LEVEL_REGEX_135 = /connector_id must be a single non-empty string for polyfill owner access/; const TOP_LEVEL_REGEX_136 = /request\.source_binding must include only kind and id/; +const TOP_LEVEL_REGEX_137 = + /Missing introspection caller credentials: set PDPP_RS_INTROSPECTION_CLIENT_ID and PDPP_RS_INTROSPECTION_CLIENT_SECRET/; +const CLI_GRANT_FIXTURE_OWNER_SUBJECTS = ["cli_owner", "u1", "employee_1"] as const; const execFile = promisify(execFileCallback); @@ -179,6 +185,14 @@ interface CloseableHttpServer { // callers of withHarness/withNativeHarness. interface TestManifest { readonly connector_id: string; + readonly name?: string; + readonly provider_id?: string; + readonly source_declaration?: { + readonly protocol_version: string; + readonly streams: readonly Record<string, unknown>[]; + }; + readonly storage_binding?: { readonly connector_id: string }; + readonly version?: string; readonly [key: string]: unknown; } @@ -413,6 +427,7 @@ async function withHarness(fn: (ctx: HarnessContext) => Promise<void>) { dynamicClientRegistrationInitialAccessTokens: [TEST_DCR_INITIAL_ACCESS_TOKEN], quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -426,6 +441,7 @@ async function withHarness(fn: (ctx: HarnessContext) => Promise<void>) { headers: { "Content-Type": "application/json" }, method: "POST", }); + await seedCliGrantInstances(spotifyManifest.connector_id, "Spotify"); await fn({ asUrl, rsUrl, spotifyManifest }); } finally { @@ -443,6 +459,7 @@ async function withNativeHarness(fn: (ctx: NativeHarnessContext) => Promise<void nativeManifest, quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -475,7 +492,7 @@ function startGrantRequest(asUrl: string, params: GrantRequestParams) { params.source || (params.provider_id ? { id: params.provider_id, kind: "provider_native" } - : { id: params.connector_id, kind: "connector" }), + : { id: sourceIdForConnectorId(params.connector_id), kind: "connector" }), streams: params.streams, type: "https://pdpp.dev/data-access", }, @@ -494,11 +511,21 @@ function approveGrantRequest( subjectId: string, extra: Record<string, unknown> = {} ) { - return fetchJson<ApprovedGrant>(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId, ...extra }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); + return (async () => { + const review = await fetchJson<Record<string, unknown>>(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId, ...extra }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + const reviewRevision = review.body.approval_review_revision; + assert.equal(typeof reviewRevision, "string", "consent review must return approval_review_revision"); + return fetchJson<ApprovedGrant>(`${asUrl}/consent/approve`, { + body: JSON.stringify({ approval_review_revision: reviewRevision, request_uri: requestUri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + })(); } async function denyGrantRequest(asUrl: string, requestUri: string) { @@ -654,6 +681,47 @@ async function materializeCliRunConnection(connectorId: string, ownerSubjectId = return namespace.connectorInstanceId; } +function sourceIdForConnectorId(connectorId: string | undefined): string | undefined { + if (connectorId === undefined || connectorId.includes("://")) { + return connectorId; + } + return `https://registry.pdpp.dev/connectors/${connectorId}`; +} + +async function seedDefaultGrantInstance( + connectorId: string, + ownerSubjectId: string, + displayName: string +): Promise<void> { + const store = createRequestConnectorInstanceStore(); + const connectorKey = canonicalConnectorKey(connectorId) ?? connectorId; + const connectorInstanceId = makeDefaultAccountConnectorInstanceId(ownerSubjectId, connectorKey); + if (await store.get(connectorInstanceId)) { + return; + } + const now = new Date().toISOString(); + await store.upsert({ + connectorId: connectorKey, + connectorInstanceId, + createdAt: now, + displayName, + ownerSubjectId, + sourceBinding: { fixture: "cli-grant-omission-default-account" }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); +} + +async function seedCliGrantInstances(connectorId: string, displayName: string): Promise<void> { + await Promise.all( + CLI_GRANT_FIXTURE_OWNER_SUBJECTS.map((ownerSubjectId) => + seedDefaultGrantInstance(connectorId, ownerSubjectId, displayName) + ) + ); +} + function seedSpotify(rsUrl: string, manifest: TestManifest, ownerToken: string, ownerSubjectId = "cli_owner") { const connectorPath = join(REFERENCE_IMPL_DIR, "connectors/seed/index.ts"); return runConnector({ @@ -667,7 +735,7 @@ function seedSpotify(rsUrl: string, manifest: TestManifest, ownerToken: string, collectionMode: "full_refresh", connectorId: manifest.connector_id, connectorPath, - manifest, + manifest: manifest as RuntimeConnectorManifest, ownerSubjectId, ownerToken, rsUrl, @@ -675,7 +743,7 @@ function seedSpotify(rsUrl: string, manifest: TestManifest, ownerToken: string, }); } -async function seedNorthstar(nativeManifest: TestManifest) { +async function seedNorthstar(nativeManifest: TestManifest, ownerSubjectId = "cli_owner") { const records = [ { data: { @@ -728,8 +796,40 @@ async function seedNorthstar(nativeManifest: TestManifest) { ]; const storageBinding = nativeManifest.storage_binding as { connector_id: string }; + assert.ok(nativeManifest.name, "native manifest includes name"); + assert.ok(nativeManifest.source_declaration, "native manifest includes source_declaration"); + assert.ok(nativeManifest.version, "native manifest includes version"); + await registerConnector( + { + connector_id: storageBinding.connector_id, + display_name: nativeManifest.name, + protocol_version: nativeManifest.source_declaration.protocol_version, + source_declaration: nativeManifest.source_declaration, + streams: nativeManifest.source_declaration.streams, + version: nativeManifest.version, + }, + { backfillRetrievalIndexes: false } + ); + + const connectorInstanceId = makeDefaultAccountConnectorInstanceId(ownerSubjectId, storageBinding.connector_id); + const now = new Date().toISOString(); + await createRequestConnectorInstanceStore().upsert({ + connectorId: storageBinding.connector_id, + connectorInstanceId, + createdAt: now, + displayName: "Northstar HR", + ownerSubjectId, + sourceBinding: { fixture: "cli-native-provider" }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); for await (const record of records) { - await ingestRecord(storageBinding.connector_id, record); + await ingestRecord( + { connector_id: storageBinding.connector_id, connector_instance_id: connectorInstanceId }, + record + ); } } @@ -745,6 +845,9 @@ function issueNorthstarClientGrant(asUrl: string, nativeManifest: TestManifest, } async function approveGrant(asUrl: string, subjectId: string, params: GrantRequestParams): Promise<ApprovedGrant> { + if (params.connector_id) { + await seedDefaultGrantInstance(params.connector_id, subjectId, "Grant fixture"); + } const { body: initiate } = await startGrantRequest(asUrl, params); assert.ok(initiate.request_uri, "expected request_uri from PAR"); @@ -846,6 +949,7 @@ async function withMalformedPolyfillClientGrant(fn: (ctx: MalformedPolyfillClien dynamicClientRegistrationInitialAccessTokens: [TEST_DCR_INITIAL_ACCESS_TOKEN], quiet: true, rsPort: server.rsPort, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const reRegisterResp = await fetchJson(`${asUrl}/connectors`, { @@ -890,9 +994,11 @@ function assertMalformedPolyfillClientArtifacts({ assert.equal(queryReceived.trace_id, traceId); const queryReceivedData = asRecord(queryReceived.data); assert.equal(queryReceivedData.query_shape, queryShape); - const queryReceivedSource = asRecord(queryReceivedData.source); - assert.equal(queryReceivedSource.kind, "connector"); - assert.equal(queryReceivedSource.id, missingConnectorId); + assert.equal( + queryReceivedData.source, + undefined, + `malformed source '${missingConnectorId}' must not be trusted for ${label}` + ); if (streamId) { assert.equal(queryReceived.stream_id, streamId); } @@ -907,11 +1013,13 @@ function assertMalformedPolyfillClientArtifacts({ assert.equal(rejectedEvent.trace_id, traceId); const rejectedEventData = asRecord(rejectedEvent.data); assert.equal(rejectedEventData.query_shape, queryShape); - const rejectedEventSource = asRecord(rejectedEventData.source); - assert.equal(rejectedEventSource.kind, "connector"); - assert.equal(rejectedEventSource.id, missingConnectorId); + assert.equal( + rejectedEventData.source, + undefined, + `malformed source '${missingConnectorId}' must not be trusted for ${label}` + ); const rejectedEventError = asRecord(rejectedEventData.error); - assert.equal(rejectedEventError.code, "not_found"); + assert.equal(rejectedEventError.code, "grant_invalid"); assert.match(String(rejectedEventError.message ?? ""), TOP_LEVEL_REGEX_3); if (streamId) { assert.equal(rejectedEvent.stream_id, streamId); @@ -949,6 +1057,8 @@ async function runCli(args: readonly string[], env: Record<string, string> = {}) ...process.env, AS_URL: "", PDPP_AS_URL: "", + PDPP_RS_INTROSPECTION_CLIENT_ID: TEST_RS_INTROSPECTION_CREDENTIALS.clientId, + PDPP_RS_INTROSPECTION_CLIENT_SECRET: TEST_RS_INTROSPECTION_CREDENTIALS.clientSecret, PDPP_RS_URL: "", RS_URL: "", ...env, @@ -979,6 +1089,8 @@ async function runCliExpectFailure(args: readonly string[], env: Record<string, ...process.env, AS_URL: "", PDPP_AS_URL: "", + PDPP_RS_INTROSPECTION_CLIENT_ID: TEST_RS_INTROSPECTION_CREDENTIALS.clientId, + PDPP_RS_INTROSPECTION_CLIENT_SECRET: TEST_RS_INTROSPECTION_CREDENTIALS.clientSecret, PDPP_RS_URL: "", RS_URL: "", ...env, @@ -1026,7 +1138,7 @@ test("PDPP CLI smoke", async (t) => { }); await t.test( - "auth introspect preserves the current native client grant shape without storage-binding leakage", + "auth introspect exposes native client authorization details without storage-binding leakage", async () => { await withNativeHarness(async ({ asUrl, rsUrl, nativeManifest }) => { await seedNorthstar(nativeManifest); @@ -1051,15 +1163,38 @@ test("PDPP CLI smoke", async (t) => { assert.equal(result.json.subject_id, "cli_owner"); assert.ok(typeof result.json.trace_id === "string" && result.json.trace_id.startsWith("trc_")); assert.ok(typeof result.json.scenario_id === "string" && result.json.scenario_id.startsWith("scn_")); - const resultGrantSource = asRecord(asRecord(result.json.grant).source); + const resultAuthorizationDetails = asRecord( + (result.json.authorization_details as readonly unknown[] | undefined)?.[0] + ); + const resultGrantSource = asRecord(resultAuthorizationDetails.source); assert.equal(resultGrantSource.kind, "provider_native"); assert.equal(resultGrantSource.id, nativeManifest.provider_id); - assert.equal("grant_storage_binding" in result.json, false); + assert.equal("grant_storage_binding" in resultAuthorizationDetails, false); assert.equal(result.stderr, ""); + + const recordsResponse = await fetch(`${rsUrl}/v1/streams/pay_statements/records`, { + headers: { Authorization: `Bearer ${approved.token}` }, + }); + assert.equal(recordsResponse.status, 200, "issued native grant reads the serving binding"); + const recordsBody = (await recordsResponse.json()) as { data?: unknown[] }; + assert.ok(recordsBody.data?.length, "native serving binding returns the seeded pay statement"); }); } ); + await t.test("auth introspect requires caller credentials from the environment", async () => { + const result = await runCliExpectFailure( + ["auth", "introspect", "--rs-url", "http://localhost:1", "--token", "token"], + { + PDPP_RS_INTROSPECTION_CLIENT_ID: "", + PDPP_RS_INTROSPECTION_CLIENT_SECRET: "", + } + ); + + assert.equal(result.code, 2); + assert.match(result.stderr, TOP_LEVEL_REGEX_137); + }); + await t.test("auth introspect preserves grant_invalid client context", async () => { const { dbPath, cleanup } = createTempDbPath(); const nativeManifest = JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, "manifests/northstar-hr.json"), "utf8")); @@ -1069,6 +1204,7 @@ test("PDPP CLI smoke", async (t) => { nativeManifest, quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -1092,6 +1228,7 @@ test("PDPP CLI smoke", async (t) => { nativeManifest, quiet: true, rsPort: server.rsPort, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const result = await runCli([ @@ -1211,7 +1348,15 @@ test("PDPP CLI smoke", async (t) => { ], { cwd: REFERENCE_IMPL_DIR, - env: { ...process.env, AS_URL: "", PDPP_AS_URL: "", PDPP_RS_URL: "", RS_URL: "" }, + env: { + ...process.env, + AS_URL: "", + PDPP_AS_URL: "", + PDPP_RS_INTROSPECTION_CLIENT_ID: TEST_RS_INTROSPECTION_CREDENTIALS.clientId, + PDPP_RS_INTROSPECTION_CLIENT_SECRET: TEST_RS_INTROSPECTION_CREDENTIALS.clientSecret, + PDPP_RS_URL: "", + RS_URL: "", + }, stdio: ["ignore", "pipe", "pipe"], } ); @@ -1983,7 +2128,7 @@ test("PDPP CLI smoke", async (t) => { assert.ok(Array.isArray(result.json)); const payStatements = result.json.find((stream) => stream.stream === "pay_statements"); assert.equal(payStatements.source_kind, "provider_native"); - assert.equal(payStatements.source_id, "northstar_hr"); + assert.equal(payStatements.source_id, "https://northstar.example/pdpp"); assert.equal(payStatements.primary_key, "statement_id"); assert.equal(result.stderr, ""); }); @@ -2641,7 +2786,7 @@ test("PDPP CLI smoke", async (t) => { assert.equal(rejected.object_type, "pending_consent"); assert.equal(rejected.client_id, registration.json.client_id); assert.equal(asRecord(rejected.data?.source).kind, "connector"); - assert.equal(asRecord(rejected.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(asRecord(rejected.data?.source).id, spotifyManifest.connector_id); assert.equal(asRecord(rejected.data?.error).code, "invalid_client"); assert.match(String(asRecord(rejected.data?.error).message ?? ""), TOP_LEVEL_REGEX_40); assert.equal(result.stderr, ""); @@ -2651,7 +2796,10 @@ test("PDPP CLI smoke", async (t) => { await t.test( "trace show keeps approval artifacts on the original staged trace when persisted pending trace-context drifts", async () => { - await withHarness(async ({ asUrl, spotifyManifest }) => { + await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { + const ownerToken = await issueOwnerToken(asUrl, "u1"); + await seedSpotify(rsUrl, spotifyManifest, ownerToken, "u1"); + const initiate = await startGrantRequest(asUrl, { access_mode: "single_use", client_id: "longview", @@ -2692,21 +2840,21 @@ test("PDPP CLI smoke", async (t) => { ); assert.ok(approved, "trace show should keep consent.approved on the original staged trace"); assert.equal(asRecord(approved.data?.source).kind, "connector"); - assert.equal(asRecord(approved.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(asRecord(approved.data?.source).id, spotifyManifest.connector_id); const grantIssued = (result.json.data || []).find( (event) => event.event_type === "grant.issued" && event.request_id === stagedRequestId ); assert.ok(grantIssued, "trace show should keep grant.issued on the original staged trace"); assert.equal(asRecord(grantIssued.data?.source).kind, "connector"); - assert.equal(asRecord(grantIssued.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(asRecord(grantIssued.data?.source).id, spotifyManifest.connector_id); const tokenIssued = (result.json.data || []).find( (event) => event.event_type === "token.issued" && event.request_id === stagedRequestId ); assert.ok(tokenIssued, "trace show should keep token.issued on the original staged trace"); assert.equal(asRecord(tokenIssued.data?.source).kind, "connector"); - assert.equal(asRecord(tokenIssued.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(asRecord(tokenIssued.data?.source).id, spotifyManifest.connector_id); assert.equal(tokenIssued.data?.issuance_path, "grant_approval"); assert.equal(result.stderr, ""); }); @@ -2806,8 +2954,12 @@ test("PDPP CLI smoke", async (t) => { }; }); - const approveResp = await approveGrantRequest(asUrl, requireRequestUri(initiate.body), "u1"); - assert.equal(approveResp.status, 400); + const reviewResp = await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requireRequestUri(initiate.body), subject_id: "u1" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(reviewResp.status, 400); const result = await runCli(["trace", "show", stagedTraceId, "--as-url", asUrl, "--format", "json"]); assert.ok(result.json, "expected CLI --format json output to parse"); @@ -2820,7 +2972,7 @@ test("PDPP CLI smoke", async (t) => { ); assert.ok(rejected, "trace show should keep request.rejected on the original staged trace"); assert.equal(asRecord(rejected.data?.source).kind, "connector"); - assert.equal(asRecord(rejected.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(asRecord(rejected.data?.source).id, spotifyManifest.connector_id); assert.equal(result.stderr, ""); }); } @@ -2849,8 +3001,12 @@ test("PDPP CLI smoke", async (t) => { }; }); - const approveResp = await approveGrantRequest(asUrl, requireRequestUri(initiate.body), "u1"); - assert.equal(approveResp.status, 400); + const reviewResp = await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requireRequestUri(initiate.body), subject_id: "u1" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(reviewResp.status, 400); const result = await runCli(["trace", "show", stagedTraceId, "--as-url", asUrl, "--format", "json"]); assert.ok(result.json, "expected CLI --format json output to parse"); @@ -2868,7 +3024,7 @@ test("PDPP CLI smoke", async (t) => { } ); - await t.test("trace show keeps consent-time native manifest drift artifacts inspectable", async () => { + await t.test("trace show keeps retained native authorization stable across manifest label drift", async () => { await withNativeHarness(async ({ asUrl, nativeManifest }) => { const initiate = await startGrantRequest(asUrl, { access_mode: "continuous", @@ -2879,6 +3035,7 @@ test("PDPP CLI smoke", async (t) => { streams: [{ name: "pay_statements" }], }); assert.equal(initiate.status, 201); + const stagedTrace = await readPendingConsentTraceContext(requireRequestUri(initiate.body)); await mutatePendingConsentRequest(requireRequestUri(initiate.body), (request) => { request.manifest_version = "999.0.0"; @@ -2887,30 +3044,31 @@ test("PDPP CLI smoke", async (t) => { const consentResp = await fetch( `${asUrl}/consent?request_uri=${encodeURIComponent(requireRequestUri(initiate.body))}` ); - assert.equal(consentResp.status, 400); - const requestId = consentResp.headers.get("Request-Id"); - const traceId = consentResp.headers.get("PDPP-Reference-Trace-Id"); - assert.ok(requestId, "expected Request-Id header"); - assert.ok(traceId, "expected PDPP-Reference-Trace-Id header"); - assert.ok(requestId.startsWith("req_")); - assert.ok(traceId.startsWith("trc_")); + assert.equal(consentResp.status, 200); - const result = await runCli(["trace", "show", traceId, "--as-url", asUrl, "--format", "json"]); + const approval = await approveGrantRequest(asUrl, requireRequestUri(initiate.body), "employee_1"); + assert.equal(approval.status, 200); + + const result = await runCli(["trace", "show", stagedTrace.trace_id, "--as-url", asUrl, "--format", "json"]); assert.ok(result.json, "expected CLI --format json output to parse"); assert.equal(result.json.object, "trace"); - assert.equal(result.json.trace_id, traceId); + assert.equal(result.json.trace_id, stagedTrace.trace_id); - const rejected = (result.json.data || []).find( - (event) => event.event_type === "request.rejected" && event.request_id === requestId + const approved = (result.json.data || []).find( + (event) => event.event_type === "consent.approved" && event.request_id === stagedTrace.request_id + ); + assert.ok(approved, "trace show should include consent.approved from the retained native declaration"); + assert.equal(approved.object_type, "pending_consent"); + assert.equal(approved.client_id, "longview"); + assert.equal(asRecord(approved.data?.source).kind, "provider_native"); + assert.equal(asRecord(approved.data?.source).id, nativeManifest.provider_id); + assert.equal( + (result.json.data || []).find( + (event) => event.event_type === "request.rejected" && event.request_id === stagedTrace.request_id + ), + undefined ); - assert.ok(rejected, "trace show should include request.rejected for consent-time native manifest drift"); - assert.equal(rejected.object_type, "pending_consent"); - assert.equal(rejected.client_id, "longview"); - assert.equal(asRecord(rejected.data?.source).kind, "provider_native"); - assert.equal(asRecord(rejected.data?.source).id, nativeManifest.provider_id); - assert.equal(asRecord(rejected.data?.error).code, "invalid_request"); - assert.match(String(asRecord(rejected.data?.error).message ?? ""), TOP_LEVEL_REGEX_41); assert.equal(result.stderr, ""); }); }); @@ -2954,7 +3112,7 @@ test("PDPP CLI smoke", async (t) => { assert.equal(denied.object_type, "pending_consent"); assert.equal(denied.status, "denied"); assert.equal(asRecord(denied.data?.source).kind, "connector"); - assert.equal(asRecord(denied.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(asRecord(denied.data?.source).id, spotifyManifest.connector_id); assert.equal(result.stderr, ""); }); }); @@ -2964,7 +3122,7 @@ test("PDPP CLI smoke", async (t) => { async () => { await withNativeHarness(async ({ asUrl, nativeManifest }) => { const initiate = await startGrantRequest(asUrl, { - access_mode: "single_use", + access_mode: "continuous", client_id: "longview", purpose_code: "https://pdpp.dev/purpose/financial_planning", purpose_description: "Support compensation planning and verification", @@ -3259,7 +3417,9 @@ test("PDPP CLI smoke", async (t) => { await t.test( "grant revoke failures surface correlation ids and stay inspectable through timeline and trace readers", async () => { - await withHarness(async ({ asUrl, spotifyManifest }) => { + await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { + const ownerToken = await issueOwnerToken(asUrl, "u1"); + await seedSpotify(rsUrl, spotifyManifest, ownerToken, "u1"); const approved = await approveGrant(asUrl, "u1", { access_mode: "continuous", client_id: "longview", @@ -3390,8 +3550,8 @@ test("PDPP CLI smoke", async (t) => { }); }); - await t.test("grant start fails honestly when a polyfill provider receives a native-provider request", async () => { - await withHarness(async ({ asUrl }) => { + await t.test("grant start rejects the wrong Source kind without falling back to connector storage", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { const tmpDir = mkdtempSync(join(tmpdir(), "pdpp-cli-bad-grant-")); const requestPath = join(tmpDir, "request.json"); writeFileSync( @@ -3403,7 +3563,7 @@ test("PDPP CLI smoke", async (t) => { access_mode: "single_use", purpose_code: "https://pdpp.dev/purpose/financial_planning", purpose_description: "Compare pay, equity, and benefits data", - source: { id: "northstar_hr", kind: "provider_native" }, + source: { id: spotifyManifest.connector_id, kind: "provider_native" }, streams: [{ fields: ["gross_pay", "net_pay"], name: "pay_statements" }], type: "https://pdpp.dev/data-access", }, @@ -3436,7 +3596,7 @@ test("PDPP CLI smoke", async (t) => { access_mode: "single_use", purpose_code: "https://pdpp.dev/purpose/financial_planning", purpose_description: "Compare pay, equity, and benefits data", - source: { id: "wrong_provider", kind: "provider_native" }, + source: { id: "https://unknown.example/pdpp", kind: "provider_native" }, streams: [{ fields: ["gross_pay", "net_pay"], name: "pay_statements" }], type: "https://pdpp.dev/data-access", }, @@ -3516,7 +3676,13 @@ test("PDPP CLI smoke", async (t) => { }); await t.test("agent bootstrap uses the reference-local DCR default without an explicit token", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startServer({ + asPort: 0, + dbPath: ":memory:", + quiet: true, + rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, + }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; const cacheRoot = mkdtempSync(join(tmpdir(), "pdpp-agent-bootstrap-")); @@ -3671,6 +3837,7 @@ test("PDPP CLI smoke", async (t) => { await t.test("grant timeline keeps grant-scoped state artifacts inspectable", async () => { await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { const ownerToken = await issueOwnerToken(asUrl, "cli_owner"); + await seedSpotify(rsUrl, spotifyManifest, ownerToken); const approved = await approveGrant(asUrl, "cli_owner", { access_mode: "continuous", client_display: { name: "Concert Recommendation App" }, @@ -4029,7 +4196,7 @@ test("PDPP CLI smoke", async (t) => { const rejectedResp = await fetch(`${rsUrl}/v1/streams/top_artists`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejectedResp.status, 404); + assert.equal(rejectedResp.status, 403); const requestId = rejectedResp.headers.get("Request-Id"); const traceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(requestId, "malformed polyfill client stream-metadata read should surface a request id"); @@ -4037,7 +4204,7 @@ test("PDPP CLI smoke", async (t) => { assert.ok(requestId.startsWith("req_")); assert.ok(traceId.startsWith("trc_")); const rejectedBody = asRecord(await rejectedResp.json()); - assert.equal(asRecord(rejectedBody.error).code, "not_found"); + assert.equal(asRecord(rejectedBody.error).code, "grant_invalid"); assert.match(String(asRecord(rejectedBody.error).message ?? ""), TOP_LEVEL_REGEX_64); const timeline = await runCli([ @@ -4170,10 +4337,10 @@ test("PDPP CLI smoke", async (t) => { const rejectedResp = await fetch(`${rsUrl}/v1/streams/top_artists`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejectedResp.status, 404); + assert.equal(rejectedResp.status, 403); const rejectedBody = asRecord(await rejectedResp.json()); const rejectedError = asRecord(rejectedBody.error); - assert.equal(rejectedError.code, "not_found"); + assert.equal(rejectedError.code, "grant_invalid"); assert.match(String(rejectedError.message ?? ""), TOP_LEVEL_REGEX_71); return { requestId: rejectedResp.headers.get("Request-Id"), @@ -4206,7 +4373,7 @@ test("PDPP CLI smoke", async (t) => { for await (const scenario of scenarios) { await withMalformedPolyfillClientGrant(async ({ asUrl, rsUrl, approved, visibleRecord, missingConnectorId }) => { const failure = await scenario.trigger({ approved, rsUrl, visibleRecord }); - assert.match(failure.stderr || "Unknown connector: missing_spotify_connector", TOP_LEVEL_REGEX_72); + assert.match(failure.stderr || "Grant is malformed or no longer valid", TOP_LEVEL_REGEX_72); const requestId = failure.requestId || failure.stderr?.match(TOP_LEVEL_REGEX_73)?.[1]; const traceId = failure.traceId || failure.stderr?.match(TOP_LEVEL_REGEX_74)?.[1]; assert.ok(requestId, `malformed polyfill client ${scenario.label} should surface a request id`); @@ -4253,10 +4420,10 @@ test("PDPP CLI smoke", async (t) => { const changesSince = Buffer.from(JSON.stringify({ kind: "changes_since", version: 0 })).toString("base64"); const rejectedResp = await fetch( - `${rsUrl}/v1/streams/top_artists/records?changes_since=${encodeURIComponent(changesSince)}&filter[popularity]=96`, + `${rsUrl}/v1/streams/top_artists/records?changes_since=${encodeURIComponent(changesSince)}&filter[popularity][eq]=96`, { headers: { Authorization: `Bearer ${approved.token}` } } ); - assert.equal(rejectedResp.status, 403); + assert.equal(rejectedResp.status, 400); const rejectedRequestId = rejectedResp.headers.get("Request-Id"); const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(rejectedRequestId, "expected rejectedRequestId to be present"); @@ -4264,8 +4431,8 @@ test("PDPP CLI smoke", async (t) => { assert.ok(rejectedTraceId, "expected rejectedTraceId to be present"); assert.ok(rejectedTraceId.startsWith("trc_")); const rejectedBody = asRecord(await rejectedResp.json()); - assert.equal(asRecord(rejectedBody.error).code, "field_not_granted"); - assert.match(String(asRecord(rejectedBody.error).message ?? ""), TOP_LEVEL_REGEX_75); + assert.equal(asRecord(rejectedBody.error).code, "invalid_request"); + assert.match(String(asRecord(rejectedBody.error).message ?? ""), TOP_LEVEL_REGEX_CLIENT_FILTER_UNSUPPORTED); const timeline = await runCli([ "grant", @@ -4287,7 +4454,7 @@ test("PDPP CLI smoke", async (t) => { assert.equal(queryReceived.data?.query_shape, "record_list"); assert.equal(queryReceived.data?.has_changes_since, true); assert.equal(asRecord(queryReceived.data?.source).kind, "connector"); - assert.equal(asRecord(queryReceived.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(asRecord(queryReceived.data?.source).id, spotifyManifest.connector_id); const rejectedEvent = (timeline.json.data || []).find( (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId @@ -4298,9 +4465,12 @@ test("PDPP CLI smoke", async (t) => { assert.equal(rejectedEvent.data?.query_shape, "record_list"); assert.equal(rejectedEvent.data?.has_changes_since, true); assert.equal(asRecord(rejectedEvent.data?.source).kind, "connector"); - assert.equal(asRecord(rejectedEvent.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); - assert.equal(asRecord(rejectedEvent.data?.error).code, "field_not_granted"); - assert.match(String(asRecord(rejectedEvent.data?.error).message ?? ""), TOP_LEVEL_REGEX_76); + assert.equal(asRecord(rejectedEvent.data?.source).id, spotifyManifest.connector_id); + assert.equal(asRecord(rejectedEvent.data?.error).code, "invalid_request"); + assert.match( + String(asRecord(rejectedEvent.data?.error).message ?? ""), + TOP_LEVEL_REGEX_CLIENT_FILTER_UNSUPPORTED + ); const servedEvent = (timeline.json.data || []).find( (event) => event.event_type === "disclosure.served" && event.object_id === rejectedRequestId @@ -4365,7 +4535,7 @@ test("PDPP CLI smoke", async (t) => { assert.equal(queryReceived.data?.query_shape, "record_detail"); assert.equal(queryReceived.data?.requested_record_id, rejectedId); assert.equal(asRecord(queryReceived.data?.source).kind, "connector"); - assert.equal(asRecord(queryReceived.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(asRecord(queryReceived.data?.source).id, spotifyManifest.connector_id); const rejectedEvent = (timeline.json.data || []).find( (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId @@ -4376,7 +4546,7 @@ test("PDPP CLI smoke", async (t) => { assert.equal(rejectedEvent.data?.query_shape, "record_detail"); assert.equal(rejectedEvent.data?.requested_record_id, rejectedId); assert.equal(asRecord(rejectedEvent.data?.source).kind, "connector"); - assert.equal(asRecord(rejectedEvent.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(asRecord(rejectedEvent.data?.source).id, spotifyManifest.connector_id); assert.equal(asRecord(rejectedEvent.data?.error).code, "not_found"); assert.match(String(asRecord(rejectedEvent.data?.error).message ?? ""), TOP_LEVEL_REGEX_78); @@ -4403,7 +4573,7 @@ test("PDPP CLI smoke", async (t) => { assert.ok(hiddenRecord, "expected an owner-visible saved_tracks record outside the client grant"); assert.ok(hiddenRecord.id, "expected the hidden saved_tracks record to carry an id"); - const approved = await approveGrant(asUrl, "cli_owner", { + await approveGrant(asUrl, "cli_owner", { access_mode: "single_use", client_display: { name: "Concert Recommendation App" }, client_id: "concert_recommendation_app", @@ -4415,56 +4585,68 @@ test("PDPP CLI smoke", async (t) => { const scenarios = [ { - expectedCode: "grant_stream_not_allowed", - expectedMessage: TOP_LEVEL_REGEX_79, - expectStatus: 403, + expectedBodyMessage: TOP_LEVEL_REGEX_INTROSPECTION_FAILED, + expectedCode: "context.stream_not_allowed", + expectedTimelineMessage: TOP_LEVEL_REGEX_INVALID_TOKEN, + expectStatus: 401, label: "stream-metadata reads", queryShape: "stream_metadata", streamId: "recently_played", - trigger: () => + trigger: (token: string) => fetch(`${rsUrl}/v1/streams/recently_played`, { - headers: { Authorization: `Bearer ${approved.token}` }, + headers: { Authorization: `Bearer ${token}` }, }), }, { - expectedCode: "grant_stream_not_allowed", - expectedMessage: TOP_LEVEL_REGEX_80, - expectStatus: 403, + expectedBodyMessage: TOP_LEVEL_REGEX_INTROSPECTION_FAILED, + expectedCode: "context.stream_not_allowed", + expectedTimelineMessage: TOP_LEVEL_REGEX_INVALID_TOKEN, + expectStatus: 401, label: "record-list reads", queryShape: "record_list", streamId: "recently_played", - trigger: () => + trigger: (token: string) => fetch(`${rsUrl}/v1/streams/recently_played/records?limit=1`, { - headers: { Authorization: `Bearer ${approved.token}` }, + headers: { Authorization: `Bearer ${token}` }, }), }, { - expectedCode: "grant_stream_not_allowed", - expectedMessage: TOP_LEVEL_REGEX_81, - expectStatus: 403, + expectedBodyMessage: TOP_LEVEL_REGEX_INTROSPECTION_FAILED, + expectedCode: "context.stream_not_allowed", + expectedTimelineMessage: TOP_LEVEL_REGEX_INVALID_TOKEN, + expectStatus: 401, label: "record-detail reads", queryShape: "record_detail", requestedRecordId: hiddenRecord.id, streamId: "saved_tracks", - trigger: () => + trigger: (token: string) => fetch(`${rsUrl}/v1/streams/saved_tracks/records/${encodeURIComponent(String(hiddenRecord.id))}`, { - headers: { Authorization: `Bearer ${approved.token}` }, + headers: { Authorization: `Bearer ${token}` }, }), }, ]; for await (const scenario of scenarios) { - const rejectedResp = await scenario.trigger(); - assert.equal(rejectedResp.status, scenario.expectStatus); + const approved = await approveGrant(asUrl, "cli_owner", { + access_mode: "continuous", + client_display: { name: "Concert Recommendation App" }, + client_id: "concert_recommendation_app", + purpose_code: "https://pdpp.dev/purpose/personalization", + purpose_description: "Recommend concerts using top artists only", + source: { id: spotifyManifest.connector_id, kind: "connector" }, + streams: [{ name: "top_artists", view: "basic" }], + }); + const rejectedResp = await scenario.trigger(approved.token); + const rejectedBody = asRecord(await rejectedResp.clone().json()); + assert.equal(rejectedResp.status, scenario.expectStatus, JSON.stringify({ body: rejectedBody, scenario })); const rejectedRequestId = rejectedResp.headers.get("Request-Id"); const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(rejectedRequestId, "expected rejectedRequestId to be present"); assert.ok(rejectedRequestId.startsWith("req_")); assert.ok(rejectedTraceId, "expected rejectedTraceId to be present"); assert.ok(rejectedTraceId.startsWith("trc_")); - const rejectedBody = asRecord(await rejectedResp.json()); assert.equal(asRecord(rejectedBody.error).code, scenario.expectedCode); - assert.match(String(asRecord(rejectedBody.error).message ?? ""), scenario.expectedMessage); + assert.match(String(asRecord(rejectedBody.error).message ?? ""), scenario.expectedBodyMessage); const timeline = await runCli([ "grant", @@ -4485,8 +4667,12 @@ test("PDPP CLI smoke", async (t) => { assert.equal(queryReceived.stream_id, scenario.streamId); assert.equal(queryReceived.data?.query_shape, scenario.queryShape); assert.equal(queryReceived.data?.requested_record_id ?? null, scenario.requestedRecordId ?? null); - assert.equal(asRecord(queryReceived.data?.source).kind, "connector"); - assert.equal(asRecord(queryReceived.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + if (scenario.expectStatus === 403) { + assert.equal(asRecord(queryReceived.data?.source).kind, "connector"); + assert.equal(asRecord(queryReceived.data?.source).id, spotifyManifest.connector_id); + } else { + assert.equal(queryReceived.data?.source, undefined); + } const rejectedEvent = (timeline.json.data || []).find( (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId @@ -4496,10 +4682,14 @@ test("PDPP CLI smoke", async (t) => { assert.equal(rejectedEvent.stream_id, scenario.streamId); assert.equal(rejectedEvent.data?.query_shape, scenario.queryShape); assert.equal(rejectedEvent.data?.requested_record_id ?? null, scenario.requestedRecordId ?? null); - assert.equal(asRecord(rejectedEvent.data?.source).kind, "connector"); - assert.equal(asRecord(rejectedEvent.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + if (scenario.expectStatus === 403) { + assert.equal(asRecord(rejectedEvent.data?.source).kind, "connector"); + assert.equal(asRecord(rejectedEvent.data?.source).id, spotifyManifest.connector_id); + } else { + assert.equal(rejectedEvent.data?.source, undefined); + } assert.equal(asRecord(rejectedEvent.data?.error).code, scenario.expectedCode); - assert.match(String(asRecord(rejectedEvent.data?.error).message ?? ""), scenario.expectedMessage); + assert.match(String(asRecord(rejectedEvent.data?.error).message ?? ""), scenario.expectedTimelineMessage); const servedEvent = (timeline.json.data || []).find( (event) => event.event_type === "disclosure.served" && event.object_id === rejectedRequestId @@ -4592,7 +4782,7 @@ test("PDPP CLI smoke", async (t) => { assert.equal(queryReceived.data?.query_shape, "record_detail"); assert.equal(queryReceived.data?.requested_record_id, hiddenRecord.id); assert.equal(asRecord(queryReceived.data?.source).kind, "connector"); - assert.equal(asRecord(queryReceived.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(asRecord(queryReceived.data?.source).id, spotifyManifest.connector_id); const rejectedEvent = (timeline.json.data || []).find( (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId @@ -4606,7 +4796,7 @@ test("PDPP CLI smoke", async (t) => { assert.equal(rejectedEvent.data?.query_shape, "record_detail"); assert.equal(rejectedEvent.data?.requested_record_id, hiddenRecord.id); assert.equal(asRecord(rejectedEvent.data?.source).kind, "connector"); - assert.equal(asRecord(rejectedEvent.data?.source).id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(asRecord(rejectedEvent.data?.source).id, spotifyManifest.connector_id); assert.equal(asRecord(rejectedEvent.data?.error).code, "not_found"); assert.match(String(asRecord(rejectedEvent.data?.error).message ?? ""), TOP_LEVEL_REGEX_83); @@ -4701,7 +4891,7 @@ test("PDPP CLI smoke", async (t) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, ownerToken, rsUrl, state: null, @@ -4780,7 +4970,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async (message: unknown) => ({ data: { token: "super_secret_token" }, request_id: (message as { request_id?: string }).request_id, @@ -4874,7 +5064,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async () => new Promise(() => undefined), ownerToken, rsUrl, @@ -4949,7 +5139,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: () => Promise.reject(new Error("user aborted interaction")), ownerToken, rsUrl, @@ -5019,7 +5209,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async (message: unknown) => ({ request_id: (message as { request_id?: string }).request_id, status: "success", @@ -5118,7 +5308,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async () => ({ data: {}, request_id: "cli_run_interaction_invalid_envelope", @@ -5204,7 +5394,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async () => ({ data: {}, request_id: "cli_run_interaction_invalid_schema", @@ -5287,7 +5477,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, ownerToken, rsUrl, state: null, @@ -5364,7 +5554,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, ownerToken, rsUrl, state: null, @@ -5440,7 +5630,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, ownerToken, rsUrl, state: null, @@ -5517,7 +5707,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, ownerToken, rsUrl, state: null, @@ -5593,7 +5783,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, ownerToken, rsUrl, state: null, @@ -5753,7 +5943,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async () => new Promise(() => undefined), ownerToken, rsUrl, @@ -5841,7 +6031,7 @@ rl.on('line', (line) => { connectorId: spotifyManifest.connector_id, connectorInstanceId, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, ownerToken, rsUrl, state: null, @@ -5936,7 +6126,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, ownerToken: "invalid_owner_token", rsUrl: `http://localhost:${rsPort}`, state: null, @@ -6049,7 +6239,7 @@ rl.on('line', (line) => { collectionMode: "incremental", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, ownerToken: "client_token_instead_of_owner", persistState: true, rsUrl: `http://localhost:${rsPort}`, @@ -6162,7 +6352,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, ownerToken: "owner_token", rsUrl: `http://localhost:${rsPort}`, state: null, @@ -6267,7 +6457,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, ownerToken: "owner_token", rsUrl: `http://localhost:${rsPort}`, state: null, @@ -6350,7 +6540,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async () => ({}), ownerToken, rsUrl, @@ -6422,7 +6612,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async () => ({}), ownerToken, rsUrl, @@ -6509,7 +6699,7 @@ rl.on('line', (line) => { collectionMode: "incremental", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async () => ({}), ownerToken, persistState: true, @@ -6606,7 +6796,7 @@ rl.on('line', (line) => { collectionMode: "incremental", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async () => ({}), ownerToken, persistState: true, @@ -6727,7 +6917,7 @@ rl.on('line', (line) => { collectionMode: "incremental", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async () => ({}), ownerToken, persistState: true, @@ -6818,7 +7008,7 @@ rl.on('line', (line) => { collectionMode: "full_refresh", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async () => ({}), ownerToken, rsUrl, @@ -6925,7 +7115,7 @@ rl.on('line', (line) => { collectionMode: "incremental", connectorId: spotifyManifest.connector_id, connectorPath, - manifest: spotifyManifest, + manifest: spotifyManifest as RuntimeConnectorManifest, onInteraction: async () => ({}), ownerToken, persistState: true, @@ -6980,6 +7170,44 @@ rl.on('line', (line) => { await t.test("run timeline keeps partial checkpoint commit artifacts inspectable", async () => { const manifest = { connector_id: "https://registry.pdpp.dev/connectors/cli-run-partial-checkpoint-test", + source_declaration: { + declaration_version: "cli-run-partial-checkpoint-test-v1", + display: { name: "CLI Run Partial Checkpoint Test" }, + extensions: {}, + protocol_version: "0.1.0", + publisher: { id: "https://publishers.example/pdpp-test" }, + source: { id: "https://registry.pdpp.dev/connectors/cli-run-partial-checkpoint-test", kind: "connector" }, + streams: [ + { + name: "items", + primary_key: ["id"], + schema: { + properties: { + id: { type: "string" }, + value: { type: "string" }, + }, + required: ["id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + { + name: "other_items", + primary_key: ["id"], + schema: { + properties: { + id: { type: "string" }, + value: { type: "string" }, + }, + required: ["id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + ], + }, streams: [ { name: "items", @@ -6992,6 +7220,8 @@ rl.on('line', (line) => { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, { name: "other_items", @@ -7004,6 +7234,8 @@ rl.on('line', (line) => { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "0.1.0", @@ -7060,6 +7292,7 @@ rl.on('line', (line) => { dynamicClientRegistrationInitialAccessTokens: [TEST_DCR_INITIAL_ACCESS_TOKEN], quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const committedState: unknown[] = []; @@ -7115,7 +7348,7 @@ rl.on('line', (line) => { collectionMode: "incremental", connectorId: manifest.connector_id, connectorPath, - manifest, + manifest: manifest as RuntimeConnectorManifest, onInteraction: async () => ({}), ownerToken, persistState: true, @@ -7649,7 +7882,7 @@ rl.on('line', (line) => { assert.ok(Array.isArray(result.json.data)); assert.deepEqual( result.json.data.map((stream) => stream.name), - ["benefits_enrollments", "equity_grants", "pay_statements"] + ["pay_statements", "equity_grants", "benefits_enrollments"] ); assert.ok(result.json.request_id?.startsWith("req_")); assert.ok(result.json.reference_trace_id?.startsWith("trc_qry_")); @@ -7694,6 +7927,7 @@ rl.on('line', (line) => { nativeManifest, quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -7725,6 +7959,7 @@ rl.on('line', (line) => { nativeManifest, quiet: true, rsPort: server.rsPort, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const result = await runCliExpectFailure(["query", "streams", "--rs-url", rsUrl], { @@ -7833,6 +8068,7 @@ rl.on('line', (line) => { nativeManifest, quiet: true, rsPort: server.rsPort, + ...TEST_INTROSPECTION_SERVER_OPTS, }); }, }, @@ -7877,6 +8113,7 @@ rl.on('line', (line) => { nativeManifest, quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -7957,6 +8194,7 @@ rl.on('line', (line) => { nativeManifest, quiet: true, rsPort: server.rsPort, + ...TEST_INTROSPECTION_SERVER_OPTS, }); }, }, @@ -8001,6 +8239,7 @@ rl.on('line', (line) => { nativeManifest, quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -8106,6 +8345,7 @@ rl.on('line', (line) => { nativeManifest, quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; diff --git a/reference-implementation/test/client-event-subscriptions-e2e.test.ts b/reference-implementation/test/client-event-subscriptions-e2e.test.ts index df0e65411..8567a0fc7 100644 --- a/reference-implementation/test/client-event-subscriptions-e2e.test.ts +++ b/reference-implementation/test/client-event-subscriptions-e2e.test.ts @@ -27,8 +27,11 @@ import { issueOwnerToken as issueOwnerTokenRecord, registerDynamicClient, } from "../server/auth.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; import { getSubscriptionSummary } from "../server/stores/client-event-subscription-store.ts"; +import { makeDefaultAccountConnectorInstanceId } from "../server/stores/connector-instance-store.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); @@ -121,14 +124,48 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName method: "POST", }) ).body; - const approved = ( - await fetchJson<ApprovedGrant>(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: "e2e_owner" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }) - ).body; - return approved.token; + const review = await fetchJson<{ + approval_review?: unknown; + approval_review_revision?: unknown; + }>(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: "e2e_owner" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + assert.ok(review.body.approval_review && typeof review.body.approval_review === "object"); + assert.equal(typeof review.body.approval_review_revision, "string"); + const approved = await fetchJson<ApprovedGrant>(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: review.body.approval_review_revision, + request_uri: par.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(approved.status, 200, JSON.stringify(approved.body)); + assert.ok(approved.body.token); + return approved.body.token; +} + +async function seedE2eConnectorInstance(connectorId: string): Promise<string> { + const connectorKey = canonicalConnectorKey(connectorId); + assert.ok(connectorKey, `expected a canonical connector key for ${connectorId}`); + const connectorInstanceId = makeDefaultAccountConnectorInstanceId("e2e_owner", connectorKey); + const now = new Date().toISOString(); + await createRequestConnectorInstanceStore().upsert({ + connectorId: connectorKey, + connectorInstanceId, + createdAt: now, + displayName: "E2E Spotify account", + ownerSubjectId: "e2e_owner", + sourceBinding: { fixture: "client-event-subscriptions-e2e" }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); + return connectorInstanceId; } interface CloudEventPayload { @@ -342,6 +379,7 @@ test("client event subscriptions deliver signed hints end-to-end", async () => { ).status, 201 ); + await seedE2eConnectorInstance(connectorId); const ownerToken = await issueOwnerDeviceToken(asUrl); const clientToken = await approveClientGrant(asUrl, connectorId, "top_artists"); @@ -472,6 +510,7 @@ test("grant revoke disables subscription and notifies client", async () => { headers: { "Content-Type": "application/json" }, method: "POST", }); + await seedE2eConnectorInstance(connectorId); const ownerToken = await issueOwnerDeviceToken(asUrl); const clientToken = await approveClientGrant(asUrl, connectorId, "top_artists"); @@ -533,6 +572,7 @@ test("trusted owner-agent event subscriptions deliver signed hints and are revok headers: { "Content-Type": "application/json" }, method: "POST", }); + await seedE2eConnectorInstance(connectorId); const ownerSubjectId = "e2e_owner"; const registered = await registerDynamicClient( @@ -752,13 +792,15 @@ test("registered owner bearer cannot see client-grant subscriptions", async () = headers: { "Content-Type": "application/json" }, method: "POST", }); + await seedE2eConnectorInstance(spotifyManifest.connector_id); const ownerToken = await issueOwnerDeviceToken(asUrl); const clientToken = await approveClientGrant(asUrl, spotifyManifest.connector_id, "top_artists"); - await fetchJson(`${rsUrl}/v1/event-subscriptions`, { + const created = await fetchJson(`${rsUrl}/v1/event-subscriptions`, { body: JSON.stringify({ callback_url: receiver.url }), headers: { Authorization: `Bearer ${clientToken}`, "Content-Type": "application/json" }, method: "POST", }); + assert.equal(created.status, 201, JSON.stringify(created.body)); const ownerListResp = await fetchJson<SubscriptionListBody>(`${rsUrl}/v1/event-subscriptions`, { headers: { Authorization: `Bearer ${ownerToken}` }, }); diff --git a/reference-implementation/test/client-expand-closure.test.ts b/reference-implementation/test/client-expand-closure.test.ts new file mode 100644 index 000000000..ef2c1daaa --- /dev/null +++ b/reference-implementation/test/client-expand-closure.test.ts @@ -0,0 +1,307 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Client expansion closure oracles. + * + * A resolved v0.1 grant does not freeze relationship authority. Client reads + * must therefore reject expand[] and expand_limit[...] before consulting the + * current SourceDeclaration. Owner reads retain current-capability expansion. + * + * Valid expandable foreign keys are required schema fields, and issuance adds + * required fields to the resolved grant. These tests pin that fact instead of + * reproducing the review's invalid hidden-FK premise. They then prove the real + * closure defect: current metadata can repoint the same relationship name. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { configureNativeManifest, registerConnector } from "../server/auth.ts"; +import { closeDb } from "../server/db.ts"; +import { startServer } from "../server/index.ts"; +import { closePostgresStorage } from "../server/postgres-storage.ts"; +import { ingestRecord } from "../server/records.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; + +interface Backend { + databaseUrl?: string; + name: "postgres" | "sqlite"; +} + +interface JsonObject { + [key: string]: any; +} + +interface TestServer { + asPort: number; + asServer: import("node:http").Server; + rsPort: number; + rsServer: import("node:http").Server; +} + +const CLIENT_ID = "concert_recommendation_app"; +const OWNER_ID = "client_expand_closure_owner"; +const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; + +function runtimeManifest(connectorKey: string): JsonObject { + return { + capabilities: { human_interaction: [] }, + connector_id: connectorKey, + connector_key: connectorKey, + display_name: "Client expansion closure", + manifest_uri: `https://implementations.example/connectors/${connectorKey}`, + protocol_version: "0.1.0", + streams: [ + { + name: "parents", + primary_key: ["id"], + query: { expand: [{ default_limit: 10, max_limit: 20, name: "children" }] }, + relationships: [ + { + cardinality: "has_many", + foreign_key: "parent_id", + name: "children", + stream: "children", + }, + ], + schema: { + properties: { id: { type: "string" }, title: { type: "string" } }, + required: ["id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + { + name: "children", + primary_key: ["id"], + schema: { + properties: { + id: { type: "string" }, + parent_id: { type: "string" }, + visible: { type: "string" }, + }, + required: ["id", "parent_id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + { + name: "alternate_children", + primary_key: ["id"], + schema: { + properties: { + alternate_parent_id: { type: "string" }, + id: { type: "string" }, + visible: { type: "string" }, + }, + required: ["id", "alternate_parent_id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + ], + version: "1.0.0", + }; +} + +function localFulfillment(sourceId: string, connectorKey: string): JsonObject { + const runtime = runtimeManifest(connectorKey); + return { + source_declaration: { + declaration_version: "client-expand-closure-v1", + display: { name: "Client expansion closure" }, + extensions: {}, + protocol_version: "0.1.0", + publisher: { id: "https://publishers.example/pdpp-test" }, + source: { id: sourceId, kind: "provider_native" }, + streams: runtime.streams, + }, + storage_binding: { connector_id: connectorKey }, + streams: runtime.streams, + }; +} + +async function fetchJson(url: string, options: RequestInit = {}): Promise<{ body: JsonObject; status: number }> { + const response = await fetch(url, options); + const text = await response.text(); + return { body: text ? (JSON.parse(text) as JsonObject) : {}, status: response.status }; +} + +async function closeServer(server: TestServer | null): Promise<void> { + if (!server) { + return; + } + server.asServer.closeAllConnections(); + server.rsServer.closeAllConnections(); + await Promise.allSettled([ + new Promise<void>((resolve) => server.asServer.close(() => resolve())), + new Promise<void>((resolve) => server.rsServer.close(() => resolve())), + ]); +} + +async function approveGrant(asUrl: string, sourceId: string): Promise<JsonObject> { + const initiated = await fetchJson(`${asUrl}/oauth/par`, { + body: JSON.stringify({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/client-expand-closure-test", + source: { id: sourceId, kind: "provider_native" }, + streams: [ + { fields: ["id", "title"], name: "parents" }, + // Omit both child foreign keys from the request. Issuance must add + // them because valid expandable foreign keys are schema-required. + { fields: ["id", "visible"], name: "children" }, + { fields: ["id", "visible"], name: "alternate_children" }, + ], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(initiated.status, 201, JSON.stringify(initiated.body)); + + const review = await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: initiated.body.request_uri, subject_id: OWNER_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + + const approved = await fetchJson(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: review.body.approval_review_revision, + request_uri: initiated.body.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(approved.status, 200, JSON.stringify(approved.body)); + assert.ok(approved.body.token, "approval must issue a client token"); + const childrenGrant = approved.body.grant.streams.find((stream: JsonObject) => stream.name === "children"); + const alternateChildrenGrant = approved.body.grant.streams.find( + (stream: JsonObject) => stream.name === "alternate_children" + ); + assert.deepEqual(childrenGrant.fields, ["id", "visible", "parent_id"]); + assert.deepEqual(alternateChildrenGrant.fields, ["id", "visible", "alternate_parent_id"]); + return approved.body; +} + +function assertClientExpansionRejected( + response: { body: JsonObject; status: number }, + param: "expand" | "expand_limit" +): void { + assert.equal(response.status, 400, JSON.stringify(response.body)); + assert.equal(response.body.error?.code, "invalid_request"); + assert.equal(response.body.error?.param, param); + assert.equal( + response.body.error?.message, + `${param === "expand" ? "expand[]" : "expand_limit[...]"} is not supported for client-token reads in PDPP v0.1` + ); +} + +async function runClientExpansionClosure(backend: Backend): Promise<void> { + const suffix = `${backend.name}_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`; + const connectorKey = `client_expand_closure_${suffix}`; + const connectorInstanceId = `cin_${suffix}`; + const sourceId = `https://sources.example/${suffix}`; + const fulfillment = localFulfillment(sourceId, connectorKey); + let server: TestServer | null = null; + + try { + server = (await startServer({ + asPort: 0, + ...(backend.databaseUrl ? { databaseUrl: backend.databaseUrl, storageBackend: "postgres" as const } : {}), + dbPath: ":memory:", + nativeManifest: fulfillment, + quiet: true, + reconcilePolyfillManifests: false, + rsPort: 0, + startClientEventDeliveryWorker: false, + })) as TestServer; + + await registerConnector(runtimeManifest(connectorKey)); + const now = new Date().toISOString(); + await createRequestConnectorInstanceStore().upsert({ + connectorId: connectorKey, + connectorInstanceId, + createdAt: now, + displayName: "Client expansion closure", + ownerSubjectId: OWNER_ID, + sourceBinding: { fixture: suffix }, + sourceBindingKey: suffix, + sourceKind: "manual", + status: "active", + updatedAt: now, + }); + + const storageTarget = { connector_id: connectorKey, connector_instance_id: connectorInstanceId }; + await ingestRecord(storageTarget, { + data: { id: "parent-1", title: "Parent" }, + key: "parent-1", + stream: "parents", + }); + await ingestRecord(storageTarget, { + data: { id: "child-1", parent_id: "parent-1", visible: "original relation" }, + key: "child-1", + stream: "children", + }); + await ingestRecord(storageTarget, { + data: { alternate_parent_id: "parent-1", id: "alternate-child-1", visible: "mutated relation" }, + key: "alternate-child-1", + stream: "alternate_children", + }); + + const asUrl = `http://localhost:${server.asPort}`; + const rsUrl = `http://localhost:${server.rsPort}`; + const approved = await approveGrant(asUrl, sourceId); + const auth = { headers: { Authorization: `Bearer ${approved.token as string}` } }; + + // Repoint the same relationship name after issuance. The grant has not + // changed; before closure, current metadata changes the nested child from + // child-1 to alternate-child-1 and interprets alternate_parent_id. + const parentStream = fulfillment.streams[0] as JsonObject; + parentStream.relationships[0] = { + cardinality: "has_many", + foreign_key: "alternate_parent_id", + name: "children", + stream: "alternate_children", + }; + + assertClientExpansionRejected( + await fetchJson(`${rsUrl}/v1/streams/parents/records?expand[]=children`, auth), + "expand" + ); + assertClientExpansionRejected( + await fetchJson(`${rsUrl}/v1/streams/parents/records/parent-1?expand[]=children`, auth), + "expand" + ); + assertClientExpansionRejected( + await fetchJson(`${rsUrl}/v1/streams/parents/records?expand_limit[children]=1`, auth), + "expand_limit" + ); + assertClientExpansionRejected(await fetchJson(`${rsUrl}/v1/search?q=visible&expand[]=children`, auth), "expand"); + } finally { + await closeServer(server); + configureNativeManifest(null); + await closePostgresStorage(); + closeDb(); + } +} + +test("client expansion rejects mutable current relationships before metadata on SQLite", () => + runClientExpansionClosure({ name: "sqlite" })); + +test("client expansion rejects mutable current relationships before metadata on PostgreSQL", { + skip: POSTGRES_URL ? false : "PDPP_TEST_POSTGRES_URL is required", +}, () => { + assert.ok(POSTGRES_URL); + return runClientExpansionClosure({ databaseUrl: POSTGRES_URL, name: "postgres" }); +}); diff --git a/reference-implementation/test/client-filter-rejection.test.ts b/reference-implementation/test/client-filter-rejection.test.ts new file mode 100644 index 000000000..79914ec47 --- /dev/null +++ b/reference-implementation/test/client-filter-rejection.test.ts @@ -0,0 +1,60 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { rejectUnsupportedClientQuery } from "../server/record-filters.ts"; + +const CLIENT_FILTER_UNSUPPORTED = /filter\[\.\.\.\] is not supported/; +const CLIENT_EXPAND_UNSUPPORTED = /expand\[\] is not supported/; +const CLIENT_EXPAND_LIMIT_UNSUPPORTED = /expand_limit\[\.\.\.\] is not supported/; + +test("client filter rejection accepts requests without a filter parameter", () => { + assert.doesNotThrow(() => rejectUnsupportedClientQuery("client", { fields: "id", limit: 25 })); +}); + +test("client filter rejection rejects exact and range filter shapes before compilation", () => { + for (const filter of [{ name: "Ada" }, { received_at: { gte: "2026-01-01T00:00:00Z" } }]) { + assert.throws( + () => rejectUnsupportedClientQuery("client", { filter }), + (error: unknown) => { + assert.equal((error as { code?: string }).code, "invalid_request"); + assert.equal((error as { param?: string }).param, "filter"); + assert.match((error as Error).message, CLIENT_FILTER_UNSUPPORTED); + return true; + } + ); + } +}); + +test("client expansion rejection accepts requests without expansion parameters", () => { + assert.doesNotThrow(() => rejectUnsupportedClientQuery("client", { fields: "id", limit: 25 })); +}); + +test("client expansion rejection covers parsed and raw expand parameter names", () => { + for (const requestParams of [{ expand: "children" }, { "expand[]": "children" }]) { + assert.throws( + () => rejectUnsupportedClientQuery("client", requestParams), + (error: unknown) => { + assert.equal((error as { code?: string }).code, "invalid_request"); + assert.equal((error as { param?: string }).param, "expand"); + assert.match((error as Error).message, CLIENT_EXPAND_UNSUPPORTED); + return true; + } + ); + } +}); + +test("client expansion rejection covers parsed and raw expand-limit parameter names", () => { + for (const requestParams of [{ expand_limit: { children: 1 } }, { "expand_limit[]": 1 }]) { + assert.throws( + () => rejectUnsupportedClientQuery("client", requestParams), + (error: unknown) => { + assert.equal((error as { code?: string }).code, "invalid_request"); + assert.equal((error as { param?: string }).param, "expand_limit"); + assert.match((error as Error).message, CLIENT_EXPAND_LIMIT_UNSUPPORTED); + return true; + } + ); + } +}); diff --git a/reference-implementation/test/collection-profile.test.ts b/reference-implementation/test/collection-profile.test.ts index 735a433c0..67e3f2c2a 100644 --- a/reference-implementation/test/collection-profile.test.ts +++ b/reference-implementation/test/collection-profile.test.ts @@ -32,12 +32,29 @@ import { type RuntimeRunConnectorResult, runConnector, } from "../runtime/index.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; -import { admitOwnerRunConnection } from "../server/stores/connector-instance-store.ts"; +import { + admitOwnerRunConnection, + createSqliteConnectorInstanceStore, +} from "../server/stores/connector-instance-store.ts"; type TestServer = Awaited<ReturnType<typeof startServer>>; +// A setup assertion can fail before a subtest reaches its own `try/finally` +// (for example, a registration response can be rejected before the connector +// fixture is created). Keep every successfully-bound pair available to the +// parent test's finalizer so an early failure cannot leave AS/RS listeners +// referenced after the test has reported its result. +const trackedTestServers = new Set<TestServer>(); + +async function startTestServer(opts: Parameters<typeof startServer>[0] = {}): Promise<TestServer> { + const server = await startServer(opts); + trackedTestServers.add(server); + return server; +} + // `runtime/index.ts` predates its own JS->TS migration and is deliberately // narrower than the real runtime/index.ts in three ways this suite exercises: // - `exit_code` is set on every terminal outcome (see the `exit_code: code` @@ -363,6 +380,7 @@ interface CloseableHttpServer { } async function closeServer(server: TestServer) { + trackedTestServers.delete(server); // Force-close keep-alive connections to prevent hanging. // Clear fallback timers when close callbacks win so the harness does not // retain stray timer handles after an otherwise clean shutdown. @@ -708,10 +726,14 @@ function buildCoEmittedStreamManifest(connectorId = "test-co-emitted-stream") { } test("Collection Profile conformance", async (t) => { + t.after(async () => { + await Promise.allSettled([...trackedTestServers].map((server) => closeServer(server))); + }); + // ── 1. RECORD processing ── await t.test("runtime sends spec-shaped START with non-empty scope and no legacy config", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -757,7 +779,7 @@ test("Collection Profile conformance", async (t) => { }); await t.test("incremental runs pass prior state through START", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -794,7 +816,7 @@ test("Collection Profile conformance", async (t) => { }); await t.test("single_use runs ignore provided prior state and pass null through START", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -1159,7 +1181,7 @@ test("Collection Profile conformance", async (t) => { await t.test( "runtime normalizes START.scope fields to include schema-required, primary_key, and time_range fields", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const manifest = { ...MINIMAL_MANIFEST, @@ -1229,7 +1251,7 @@ test("Collection Profile conformance", async (t) => { ); await t.test("connectors can branch on START.collection_mode", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const { connectorPath, cleanup } = createCollectionModeBranchConnector(); @@ -1276,7 +1298,7 @@ test("Collection Profile conformance", async (t) => { }); await t.test("connectors can branch on START.scope resources and time_range selectors", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const manifest = { ...MINIMAL_MANIFEST, @@ -1340,7 +1362,7 @@ test("Collection Profile conformance", async (t) => { }); await t.test("connectors can branch on normalized START.scope fields selectors", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const manifest = { ...MINIMAL_MANIFEST, @@ -1420,7 +1442,7 @@ test("Collection Profile conformance", async (t) => { }); await t.test("runtime ingests RECORD messages to the RS", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -1476,7 +1498,7 @@ test("Collection Profile conformance", async (t) => { // ── 2. STATE gating on DONE ── await t.test("STATE is only committed when DONE status is succeeded", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -1545,7 +1567,7 @@ test("Collection Profile conformance", async (t) => { }); await t.test("pre-progress ChatGPT failure persists actionable known gap metadata", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, { ...MINIMAL_MANIFEST, @@ -1606,7 +1628,7 @@ test("Collection Profile conformance", async (t) => { }); await t.test("pre-progress browser profile attach race remains runtime-retryable", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, { ...MINIMAL_MANIFEST, @@ -1672,7 +1694,7 @@ test("Collection Profile conformance", async (t) => { // exercised in packages/polyfill-connectors); it only proves the // runtime's generic code passthrough, independent of any specific code // value. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, { ...MINIMAL_MANIFEST, @@ -1854,7 +1876,7 @@ test("Collection Profile conformance", async (t) => { }); await t.test("runtime rejects scalar STATE.cursor values as protocol violations", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -1888,7 +1910,7 @@ test("Collection Profile conformance", async (t) => { }); await t.test("the last STATE for a stream wins when a run stages multiple checkpoints", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -1961,7 +1983,7 @@ test("Collection Profile conformance", async (t) => { await t.test( "STATE currently flushes and stages only the named stream when other streams still have buffered records", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const manifest = buildMultiStreamManifest("test-multi-stream-state-boundary"); const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); @@ -2067,7 +2089,7 @@ rl.on('line', (line) => { await t.test( "multiple staged stream checkpoints commit successfully without requiring a cross-stream ordering guarantee", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const manifest = buildMultiStreamManifest("test-multi-stream-checkpoint-success"); const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); @@ -2145,7 +2167,7 @@ rl.on('line', (line) => { ); await t.test("multiple staged stream checkpoints still commit nothing when the run fails after staging", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const manifest = buildMultiStreamManifest("test-multi-stream-checkpoint-failure"); const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); @@ -2250,7 +2272,7 @@ rl.on('line', (line) => { await t.test( "checkpoint persistence failures after DONE(succeeded) stay inspectable and expose partial commit counts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const asUrl = `http://localhost:${server.asPort}`; const connectorId = "partial-checkpoint-commit"; const manifest = { @@ -2285,7 +2307,13 @@ rl.on('line', (line) => { }; const registerResp = await fetchJson(`${asUrl}/connectors`, { - body: JSON.stringify(manifest), + body: JSON.stringify( + withTestSourceDeclaration({ + ...manifest, + display_name: connectorId, + protocol_version: "0.1.0", + }) + ), headers: { "Content-Type": "application/json" }, method: "POST", }); @@ -2427,7 +2455,7 @@ rl.on('line', (line) => { // ── 3. single_use: null START.state and no STATE persistence ── await t.test("single_use runs do not persist STATE even on success", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -2474,12 +2502,13 @@ rl.on('line', (line) => { }); await t.test("grant-scoped STATE stays isolated from global state and other grants", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + t.after(() => closeServer(server)); const { asPort, rsPort } = server; - const { ownerToken, connectorId } = await setupConnector(server, asPort); + const { ownerToken, connectorId, sourceId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; - const grantAId = await createGrant(asUrl, connectorId, "test_user"); - const grantBId = await createGrant(asUrl, connectorId, "test_user"); + const grantAId = await createGrant(asUrl, sourceId, "test_user"); + const grantBId = await createGrant(asUrl, sourceId, "test_user"); const { connectorPath: grantPath, cleanup: cleanupGrant } = createTestConnector([ { cursor: { cursor: "cursor_from_grant_a" }, stream: "items", type: "STATE" }, @@ -2557,16 +2586,16 @@ rl.on('line', (line) => { } finally { cleanupGrant(); cleanupGlobal(); - await closeServer(server); } }); await t.test("single_use with grant-scoped STATE still persists nothing", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + t.after(() => closeServer(server)); const { asPort, rsPort } = server; - const { ownerToken, connectorId } = await setupConnector(server, asPort); + const { ownerToken, connectorId, sourceId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; - const grantId = await createGrant(asUrl, connectorId, "test_user"); + const grantId = await createGrant(asUrl, sourceId, "test_user"); const { connectorPath, cleanup } = createTestConnector([ { cursor: { cursor: "should_not_persist_grant_state" }, stream: "items", type: "STATE" }, @@ -2599,12 +2628,11 @@ rl.on('line', (line) => { assert.ok(!globalState?.items, "single_use grant runs should not leak into global state"); } finally { cleanup(); - await closeServer(server); } }); await t.test("runtime rejects RECORD messages outside declared START.scope", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -2644,7 +2672,7 @@ rl.on('line', (line) => { }); await t.test("runtime rejects RECORD messages outside declared START.scope resources", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -2684,7 +2712,7 @@ rl.on('line', (line) => { }); await t.test("runtime accepts RECORD messages within manifest-declared resource field", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const manifest = { ...MINIMAL_MANIFEST, @@ -2741,7 +2769,7 @@ rl.on('line', (line) => { }); await t.test("runtime rejects RECORD messages with fields outside START.scope", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -2781,7 +2809,7 @@ rl.on('line', (line) => { }); await t.test("runtime rejects RECORD messages outside declared START.scope time_range", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const manifest = { ...MINIMAL_MANIFEST, @@ -2979,7 +3007,7 @@ rl.on('line', (line) => { // ── 5. SKIP_RESULT handling ── await t.test("runtime accepts SKIP_RESULT messages without error", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -3039,7 +3067,7 @@ rl.on('line', (line) => { // a bounded, redacted projection to the run.stream_skipped spine event and // to the known_gap so the owner can diagnose the failure offline. await t.test("runtime forwards bounded SKIP_RESULT.diagnostics into the spine event and known gap", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -3110,7 +3138,7 @@ rl.on('line', (line) => { }); await t.test("runtime redacts secret-shaped strings inside SKIP_RESULT.diagnostics", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -3312,7 +3340,7 @@ rl.on('line', (line) => { }, ]) { // biome-ignore lint/performance/noAwaitInLoops: localized test assertion preserves its explicit contract. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -3371,7 +3399,7 @@ rl.on('line', (line) => { }); await t.test("runtime replaces oversized SKIP_RESULT.diagnostics with a size_overflow sentinel", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -3426,7 +3454,7 @@ rl.on('line', (line) => { await t.test("runtime drops non-object SKIP_RESULT.diagnostics without rejecting the message", async () => { for (const diagnostics of ["oops", [1, 2, 3], 123]) { // biome-ignore lint/performance/noAwaitInLoops: localized test assertion preserves its explicit contract. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -3488,7 +3516,7 @@ rl.on('line', (line) => { await t.test("runtime preserves a valid DETAIL_COVERAGE.considered count on the spine event", async () => { const manifest = buildMultiStreamManifest("considered-coverage"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -3545,7 +3573,7 @@ rl.on('line', (line) => { for (const considered of [-1, 3.5, Number.NaN, Number.POSITIVE_INFINITY, "7", Number.MAX_SAFE_INTEGER + 1, null]) { const manifest = buildMultiStreamManifest("considered-coverage-bad"); // biome-ignore lint/performance/noAwaitInLoops: localized test assertion preserves its explicit contract. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -3602,7 +3630,7 @@ rl.on('line', (line) => { for (const considered of [0, Number.MAX_SAFE_INTEGER]) { const manifest = buildMultiStreamManifest("considered-coverage-edge"); // biome-ignore lint/performance/noAwaitInLoops: localized test assertion preserves its explicit contract. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -3651,7 +3679,7 @@ rl.on('line', (line) => { await t.test("existing DETAIL_COVERAGE with no considered stays unknown (no field) and unchanged", async () => { const manifest = buildMultiStreamManifest("considered-coverage-absent"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -3703,7 +3731,7 @@ rl.on('line', (line) => { }); await t.test("runtime preserves a valid SKIP_RESULT.diagnostics.considered count", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -3765,7 +3793,7 @@ rl.on('line', (line) => { async () => { for (const considered of [-5, 2.5, "900", Number.NaN, Number.MAX_SAFE_INTEGER + 1]) { // biome-ignore lint/performance/noAwaitInLoops: localized test assertion preserves its explicit contract. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -3838,7 +3866,7 @@ rl.on('line', (line) => { // projection-derived `collection_report` key must remain absent on the // terminal event (that is Tranche C). const manifest = buildMultiStreamManifest("facts-block-layer-boundary"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -3935,7 +3963,7 @@ rl.on('line', (line) => { // A two-stream success: each requested stream gets exactly one entry with a // raw collected count and a checkpoint fact, and NO coverage verdict. const manifest = buildMultiStreamManifest("facts-per-in-scope-stream"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -4020,7 +4048,7 @@ rl.on('line', (line) => { // With the manifest `state_stream` declaration the child inherits the // parent's committed checkpoint. const manifest = buildCoEmittedStreamManifest("facts-co-emitted-checkpoint"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -4098,7 +4126,7 @@ rl.on('line', (line) => { // missing entry; and with no declared considered, the considered key is // absent (reads unknown) — never inferred to equal collected. const manifest = buildMultiStreamManifest("facts-zero-record-stream"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -4156,7 +4184,7 @@ rl.on('line', (line) => { await t.test("2.2a: a SKIP_RESULT stream carries the skip fact, no complete verdict", async () => { // The runtime states the skip fact; deciding unsupported/etc is the // projection's job (Tranche C). The entry must NOT carry a coverage verdict. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -4211,7 +4239,7 @@ rl.on('line', (line) => { await t.test("2.2a: a pending DETAIL_GAP shows pending_detail_gaps>=1 by count, not restated locators", async () => { const manifest = buildMultiStreamManifest("facts-pending-detail-gap"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -4277,7 +4305,7 @@ rl.on('line', (line) => { "2.2a: considered honesty — declared value carried, absence stays unknown, never set to collected", async () => { const manifest = buildMultiStreamManifest("facts-considered-honesty"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -4358,7 +4386,7 @@ rl.on('line', (line) => { await t.test("2.2a: declared considered prefers DETAIL_COVERAGE.considered over required_keys.length", async () => { const manifest = buildMultiStreamManifest("facts-considered-priority"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -4409,7 +4437,7 @@ rl.on('line', (line) => { await t.test("2.2a: required_keys.length is the considered fallback when no considered is declared", async () => { const manifest = buildMultiStreamManifest("facts-considered-fallback"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -4468,7 +4496,7 @@ rl.on('line', (line) => { // gate has nothing to mark missing, so the committed STATE still commits, and // the terminal facts block carries the declared considered for that stream. const manifest = buildMultiStreamManifest("facts-list-considered"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -4550,7 +4578,7 @@ rl.on('line', (line) => { // covered count through the spine event and onto the terminal facts block, so // the projection can read covered === considered → complete. `collected` is 0. const manifest = buildMultiStreamManifest("facts-list-covered"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -4625,7 +4653,7 @@ rl.on('line', (line) => { for (const covered of [-1, 2.5, Number.NaN, Number.POSITIVE_INFINITY, "4", Number.MAX_SAFE_INTEGER + 1]) { const manifest = buildMultiStreamManifest("facts-covered-bad"); // biome-ignore lint/performance/noAwaitInLoops: localized test assertion preserves its explicit contract. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -4684,7 +4712,7 @@ rl.on('line', (line) => { // The portability floor: a connector that emits only RECORD/STATE/DONE — no // DETAIL_COVERAGE, no SKIP_RESULT — still produces a valid per-stream facts // block. Its considered axis is just absent (unknown); no derived axes. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -4744,7 +4772,7 @@ rl.on('line', (line) => { // pre-existing terminal field with its prior shape; the only addition is the // additive collection_facts block. const manifest = buildMultiStreamManifest("facts-2-7-invariant"); - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); const asUrl = `http://localhost:${asPort}`; @@ -4807,7 +4835,7 @@ rl.on('line', (line) => { // buildRunTerminalData() composes the block for every terminal event; prove // it on a connector-reported failure too, with its collected/checkpoint facts // intact and still no derived axis. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -4870,7 +4898,7 @@ rl.on('line', (line) => { }); await t.test("runtime reports known gaps for partial flush then failed terminal state", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -4933,7 +4961,7 @@ rl.on('line', (line) => { }); await t.test("runtime reports manual-action known gaps without persisting interaction responses", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5008,7 +5036,7 @@ rl.on('line', (line) => { }); await t.test("runtime rejects malformed SKIP_RESULT envelopes as protocol violations", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5062,7 +5090,7 @@ rl.on('line', (line) => { }); await t.test("runtime rejects SKIP_RESULT for undeclared streams as a protocol violation", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5116,7 +5144,7 @@ rl.on('line', (line) => { }); await t.test("runtime accepts PROGRESS messages without error", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5196,7 +5224,7 @@ rl.on('line', (line) => { }); await t.test("runtime persists collection_rate in spine events and terminal event", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5376,7 +5404,7 @@ rl.on('line', (line) => { for (const invalidPair of invalidPairs) { // biome-ignore lint/performance/noAwaitInLoops: localized test assertion preserves its explicit contract. await subT.test(invalidPair.name, async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5432,7 +5460,7 @@ rl.on('line', (line) => { ); await t.test("connection_health.collection_rate is null when no rate event has been emitted", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5494,14 +5522,18 @@ rl.on('line', (line) => { // real RECORD message, so a connector_instances row genuinely exists) // to restore that coverage, kept separate from the zero-record // unresolved tests above. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const asUrl = `http://localhost:${asPort}`; - await fetchJson(`${asUrl}/connectors`, { - body: JSON.stringify(MINIMAL_MANIFEST), + const registration = await fetchJson(`${asUrl}/connectors`, { + body: JSON.stringify(withTestSourceDeclaration(MINIMAL_MANIFEST)), headers: { "Content-Type": "application/json" }, method: "POST", }); + assert.ok( + [200, 201].includes(registration.status), + `connector registration failed: ${JSON.stringify(registration.body)}` + ); // The owner-dashboard read surface (`getConnectorDetail`) is hardcoded // to REFERENCE_OWNER_SUBJECT_ID/OWNER_AUTH_DEFAULT_SUBJECT_ID // ('owner_local') — a real single-owner security boundary. Unlike @@ -5588,7 +5620,7 @@ rl.on('line', (line) => { ); await t.test("runtime rejects malformed PROGRESS envelopes as protocol violations", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5642,7 +5674,7 @@ rl.on('line', (line) => { }); await t.test("runtime rejects malformed PROGRESS counters as protocol violations", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5698,7 +5730,7 @@ rl.on('line', (line) => { }); await t.test("runtime rejects malformed PROGRESS total counters as protocol violations", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5754,7 +5786,7 @@ rl.on('line', (line) => { }); await t.test("runtime rejects PROGRESS for undeclared streams as a protocol violation", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5835,7 +5867,7 @@ rl.on('line', (line) => { }); await t.test("runtime rejects unknown connector message types as protocol violations", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5898,7 +5930,7 @@ rl.on('line', (line) => { }); await t.test("runtime rejects invalid connector JSONL as a protocol violation", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -5976,7 +6008,7 @@ rl.on('line', (line) => { // ── 6. INTERACTION completes and connector continues ── await t.test("INTERACTION round-trip allows connector to continue collecting", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -6101,7 +6133,7 @@ rl.on('line', (line) => { }); await t.test("browser-surface-backed otp INTERACTION projects streamable assistance with secret input", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -6198,7 +6230,7 @@ rl.on('line', (line) => { }); await t.test("nonblocking ASSISTANCE records assistance without interaction-required behavior", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -6345,7 +6377,7 @@ rl.on('line', (line) => { }); await t.test("runtime terminates timed-out nonblocking ASSISTANCE that never resolves", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -6423,7 +6455,7 @@ process.on('exit', () => clearInterval(keepalive)); }); await t.test("runtime rejects response-required ASSISTANCE without compatibility path", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -6487,7 +6519,7 @@ rl.on('line', (line) => { }); await t.test("nonblocking ASSISTANCE records retry/backoff and explicit escalation transitions", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -6570,7 +6602,7 @@ rl.on('line', (line) => { }); await t.test("runtime rejects connector output emitted while waiting for INTERACTION_RESPONSE", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -6669,7 +6701,7 @@ rl.on('line', (line) => { await t.test( "runtime rejects STATE emitted while waiting for INTERACTION_RESPONSE and does not stage checkpoints", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -6769,7 +6801,7 @@ rl.on('line', (line) => { await t.test( "runtime rejects PROGRESS emitted while waiting for INTERACTION_RESPONSE and does not record progress artifacts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -6865,7 +6897,7 @@ rl.on('line', (line) => { await t.test( "runtime rejects SKIP_RESULT emitted while waiting for INTERACTION_RESPONSE and does not record skip artifacts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -6960,7 +6992,7 @@ rl.on('line', (line) => { ); await t.test("runtime rejects a second INTERACTION emitted while waiting for INTERACTION_RESPONSE", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -7061,7 +7093,7 @@ rl.on('line', (line) => { await t.test( "runtime rejects DONE emitted while waiting for INTERACTION_RESPONSE and does not record terminal artifacts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -7159,7 +7191,7 @@ rl.on('line', (line) => { await t.test( "runtime rejects invalid JSONL emitted while waiting for INTERACTION_RESPONSE and does not record completion artifacts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -7252,7 +7284,7 @@ rl.on('line', (line) => { ); await t.test("runtime returns INTERACTION timeout responses when the handler does not answer in time", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -7338,7 +7370,7 @@ rl.on('line', (line) => { }); await t.test("runtime returns INTERACTION cancelled responses when the handler aborts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -7426,7 +7458,7 @@ rl.on('line', (line) => { await t.test( "invalid INTERACTION_RESPONSE envelopes fail the run and record an explicit runtime reason", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -7515,7 +7547,7 @@ rl.on('line', (line) => { await t.test( "malformed INTERACTION envelopes fail the run before the interaction enters the durable timeline", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -7603,7 +7635,7 @@ rl.on('line', (line) => { await t.test( "malformed INTERACTION timeout_seconds fail the run before the interaction enters the durable timeline", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -7674,7 +7706,7 @@ rl.on('line', (line) => { await t.test( "malformed INTERACTION schema values fail the run before the interaction enters the durable timeline", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -7744,7 +7776,7 @@ rl.on('line', (line) => { ); await t.test("runtime rejects INTERACTION for undeclared streams as a protocol violation", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -7830,7 +7862,7 @@ rl.on('line', (line) => { await t.test( "runtime rejects INTERACTION when START.bindings omit interactive and records no interaction artifacts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -7916,7 +7948,7 @@ rl.on('line', (line) => { // ── 8. Failed DONE does not ingest remaining buffered records ── await t.test("DONE(failed) does not flush remaining buffered records", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -7990,7 +8022,7 @@ rl.on('line', (line) => { }); await t.test("DONE(failed) after staging multiple stream checkpoints still commits none of them", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const manifest = buildMultiStreamManifest("test-multi-stream-done-failed"); const { ownerToken, connectorId } = await setupConnector(server, asPort, manifest); @@ -8070,7 +8102,7 @@ rl.on('line', (line) => { await t.test( "malformed DONE.error envelopes are rejected as protocol violations before terminal artifacts are recorded as connector failures", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -8134,7 +8166,7 @@ rl.on('line', (line) => { await t.test( "DONE.error with unsupported fields is rejected as a protocol violation before terminal artifacts are recorded as connector failures", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -8203,7 +8235,7 @@ rl.on('line', (line) => { await t.test( "DONE(succeeded) with terminal error details is rejected as a protocol violation before success artifacts are recorded", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -8276,7 +8308,7 @@ rl.on('line', (line) => { await t.test( "DONE(cancelled) after staging a checkpoint commits nothing and records a cancelled terminal run", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -8365,7 +8397,7 @@ rl.on('line', (line) => { await t.test( "DONE(cancelled) with an exit code of 0 is treated as a protocol violation and commits no checkpoints", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -8469,7 +8501,7 @@ rl.on('line', (line) => { await t.test( "DONE(cancelled) with mismatched records_emitted is treated as a protocol violation and still commits no checkpoints", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -8578,7 +8610,7 @@ rl.on('line', (line) => { ); await t.test("messages after DONE are treated as protocol violations and prevent checkpoint commit", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -8694,7 +8726,7 @@ rl.on('line', (line) => { await t.test( "PROGRESS after DONE is treated as a protocol violation and never enters durable run artifacts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -8781,7 +8813,7 @@ rl.on('line', (line) => { await t.test( "INTERACTION after DONE is treated as a protocol violation and never enters durable run artifacts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -8874,7 +8906,7 @@ rl.on('line', (line) => { await t.test( "SKIP_RESULT after DONE is treated as a protocol violation and never enters durable run artifacts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -8962,7 +8994,7 @@ rl.on('line', (line) => { await t.test( "STATE after DONE is treated as a protocol violation and never stages checkpoint artifacts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -9054,7 +9086,7 @@ rl.on('line', (line) => { await t.test( "invalid JSONL after DONE is treated as a protocol violation and never records completion artifacts", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -9138,7 +9170,7 @@ rl.on('line', (line) => { await t.test( "DONE(succeeded) with a non-zero exit code is treated as a protocol violation and prevents checkpoint commit", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -9242,7 +9274,7 @@ rl.on('line', (line) => { await t.test( "DONE(failed) with an exit code of 0 is treated as a protocol violation and commits no checkpoints", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -9346,7 +9378,7 @@ rl.on('line', (line) => { await t.test( "DONE(succeeded) with mismatched records_emitted is treated as a protocol violation and prevents checkpoint commit", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -9457,7 +9489,7 @@ rl.on('line', (line) => { await t.test( "DONE(failed) with mismatched records_emitted is treated as a protocol violation and still commits no checkpoints", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -9568,7 +9600,7 @@ rl.on('line', (line) => { await t.test( "DONE with an invalid status is treated as a protocol violation and commits no checkpoints", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -9674,7 +9706,7 @@ rl.on('line', (line) => { await t.test( "unexpected connector exit before STATE fails the run, preserves no state, and leaves buffered records unflushed", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); @@ -9730,7 +9762,7 @@ rl.on('line', (line) => { await t.test( "unexpected connector exit after STATE fails the run, preserves no state, and records run.failed", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -9805,7 +9837,7 @@ rl.on('line', (line) => { ); await t.test("graceful connector exit without DONE still fails the run and records run.failed", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const asUrl = `http://localhost:${asPort}`; @@ -9873,7 +9905,7 @@ rl.on('line', (line) => { await t.test( "invalid ingest response at STATE fails specifically and preserves dropped-tail accounting", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort } = server; const asUrl = `http://localhost:${asPort}`; const connectorId = "invalid-ingest-response"; @@ -10004,7 +10036,7 @@ rl.on('line', (line) => { const rsPort = serverPort(rsServer); const registerResp = await fetchJson(`${asUrl}/connectors`, { - body: JSON.stringify(manifest), + body: JSON.stringify(withTestSourceDeclaration(manifest)), headers: { "Content-Type": "application/json" }, method: "POST", }); @@ -10080,7 +10112,7 @@ rl.on('line', (line) => { await t.test( "unexpected connector exit after a batch flush preserves flushed records but drops the remaining buffered tail", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; const { ownerToken, connectorId } = await setupConnector(server, asPort); const previousBatchSize = process.env.PDPP_RUNTIME_BATCH_SIZE; @@ -10178,7 +10210,7 @@ rl.on('line', (line) => { // ── 9. STATE remains connector-scoped across different connectors ── await t.test("STATE from one connector does not affect another connectors state", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startTestServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); const { asPort, rsPort } = server; // Register two different connectors @@ -10186,16 +10218,24 @@ rl.on('line', (line) => { const manifest1 = { ...MINIMAL_MANIFEST, connector_id: "connector-a" }; const manifest2 = { ...MINIMAL_MANIFEST, connector_id: "connector-b" }; - await fetchJson(`${asUrl}/connectors`, { - body: JSON.stringify(manifest1), + const registerA = await fetchJson(`${asUrl}/connectors`, { + body: JSON.stringify(withTestSourceDeclaration(manifest1)), headers: { "Content-Type": "application/json" }, method: "POST", }); - await fetchJson(`${asUrl}/connectors`, { - body: JSON.stringify(manifest2), + const registerB = await fetchJson(`${asUrl}/connectors`, { + body: JSON.stringify(withTestSourceDeclaration(manifest2)), headers: { "Content-Type": "application/json" }, method: "POST", }); + assert.ok( + [200, 201].includes(registerA.status), + `connector-a registration failed: ${JSON.stringify(registerA.body)}` + ); + assert.ok( + [200, 201].includes(registerB.status), + `connector-b registration failed: ${JSON.stringify(registerB.body)}` + ); const ownerToken = await issueOwnerToken(asUrl, "test_user"); // Run connector A — emits STATE @@ -10248,7 +10288,7 @@ async function fetchJson<T = GenericJsonBody>(url: string, opts: RequestInit = { } // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. -async function startGrantRequest(asUrl: string, params: { connectorId: string }) { +async function startGrantRequest(asUrl: string, params: { sourceId: string }) { return fetchJson(`${asUrl}/oauth/par`, { body: JSON.stringify({ authorization_details: [ @@ -10256,7 +10296,7 @@ async function startGrantRequest(asUrl: string, params: { connectorId: string }) access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Collection Profile conformance test grant", - source: { id: params.connectorId, kind: "connector" }, + source: { id: params.sourceId, kind: "connector" }, streams: [{ name: "items" }], type: "https://pdpp.dev/data-access", }, @@ -10268,18 +10308,34 @@ async function startGrantRequest(asUrl: string, params: { connectorId: string }) }); } -// biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. async function approveGrantRequest(asUrl: string, requestUri: string, subjectId: string) { - return fetchJson(`${asUrl}/consent/approve`, { + const review = await fetchJson(`${asUrl}/consent/review`, { body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + assert.equal( + typeof review.body.approval_review_revision, + "string", + "consent review must return approval_review_revision" + ); + return fetchJson(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: review.body.approval_review_revision, + request_uri: requestUri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); } -async function createGrant(asUrl: string, connectorId: string, subjectId: string): Promise<string> { - const { status: requestStatus, body: requestBody } = await startGrantRequest(asUrl, { connectorId }); - assert.ok([200, 201].includes(requestStatus), `expected successful PAR status, got ${requestStatus}`); +async function createGrant(asUrl: string, sourceId: string, subjectId: string): Promise<string> { + const { status: requestStatus, body: requestBody } = await startGrantRequest(asUrl, { sourceId }); + assert.ok( + [200, 201].includes(requestStatus), + `expected successful PAR status, got ${requestStatus}: ${JSON.stringify(requestBody)}` + ); assert.ok(requestBody.request_uri, "expected request_uri from PAR"); const { status: approvalStatus, body: approvalBody } = await approveGrantRequest( @@ -10293,19 +10349,67 @@ async function createGrant(asUrl: string, connectorId: string, subjectId: string return approvalBody.grant_id; } +function testSourceIdForConnector(connectorId: string): string { + return `https://sources.example/${encodeURIComponent(connectorId)}`; +} + +function withTestSourceDeclaration(manifest: ConnectorManifest): ConnectorManifest { + const streams = asArray(manifest.streams).map((stream) => { + const streamRecord = asRecord(stream); + return { + ...streamRecord, + selection: { + fields: true, + resources: true, + }, + }; + }); + return { + ...manifest, + source_declaration: { + declaration_version: `collection-profile-test:${manifest.connector_id}:${manifest.version}`, + display: { name: manifest.display_name }, + protocol_version: manifest.protocol_version, + publisher: { id: "https://sources.example/publishers/collection-profile-tests" }, + source: { id: testSourceIdForConnector(manifest.connector_id), kind: "connector" }, + streams, + }, + }; +} + async function setupConnector(_server: TestServer, asPort: number, manifest: ConnectorManifest = MINIMAL_MANIFEST) { const asUrl = `http://localhost:${asPort}`; + const registeredManifest = withTestSourceDeclaration(manifest); // Register connector manifest - await fetchJson(`${asUrl}/connectors`, { - body: JSON.stringify(manifest), + const registration = await fetchJson(`${asUrl}/connectors`, { + body: JSON.stringify(registeredManifest), headers: { "Content-Type": "application/json" }, method: "POST", }); + assert.ok( + [200, 201].includes(registration.status), + `connector registration failed: ${JSON.stringify(registration.body)}` + ); + + const connectorId = canonicalConnectorKey(manifest.connector_id) ?? manifest.connector_id; + const now = new Date().toISOString(); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: `cin_collection_${connectorId}`, + createdAt: now, + displayName: `${manifest.connector_id} test account`, + ownerSubjectId: "test_user", + sourceBinding: { fixture: "collection-profile" }, + sourceBindingKey: "collection-profile", + sourceKind: "account", + status: "active", + updatedAt: now, + }); const ownerToken = await issueOwnerToken(asUrl, "test_user"); - return { connectorId: manifest.connector_id, ownerToken }; + return { connectorId: manifest.connector_id, ownerToken, sourceId: testSourceIdForConnector(manifest.connector_id) }; } async function issueOwnerToken(asUrl: string, subjectId = "owner_local"): Promise<string> { diff --git a/reference-implementation/test/collection-report-projection-e2e.test.ts b/reference-implementation/test/collection-report-projection-e2e.test.ts index 7bbab6b09..8194817d2 100644 --- a/reference-implementation/test/collection-report-projection-e2e.test.ts +++ b/reference-implementation/test/collection-report-projection-e2e.test.ts @@ -118,24 +118,42 @@ rl.on('line', (line) => { const TWO_STREAM_MANIFEST = { connector_id: "creport-two-stream", display_name: "Collection Report Two-Stream", + manifest_uri: "https://sources.example/creport-two-stream", protocol_version: "0.1.0", streams: [ { name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" }, value: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, { name: "other_items", primary_key: ["id"], schema: { properties: { id: { type: "string" }, value: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], version: "1.0.0", }; +type RuntimeManifest = Parameters<typeof runConnector>[0]["manifest"]; + +function runtimeManifest(manifest: { + streams: ReadonlyArray<{ name: string; selection?: unknown; [key: string]: unknown }>; + [key: string]: unknown; +}): RuntimeManifest { + return { + ...manifest, + streams: manifest.streams.map((stream) => { + const { selection: _selection, ...withoutSelection } = stream; + return withoutSelection; + }), + }; +} + // Every test in this file reads back through the owner-dashboard surface // (`getConnectorDetail`/`_ref/connectors`), which is hardcoded to // REFERENCE_OWNER_SUBJECT_ID/OWNER_AUTH_DEFAULT_SUBJECT_ID ('owner_local') — @@ -257,7 +275,7 @@ test("2.2b: a two-stream run yields a two-entry collection_report on the detail collectionMode: "full_refresh", connectorId, connectorPath, - manifest: TWO_STREAM_MANIFEST, + manifest: runtimeManifest(TWO_STREAM_MANIFEST), onInteraction: async () => ({}), ownerToken, persistState: true, @@ -342,7 +360,7 @@ test("2.4: a collected-records, no-gaps, no-considered run is NOT projected comp collectionMode: "full_refresh", connectorId, connectorPath, - manifest: TWO_STREAM_MANIFEST, + manifest: runtimeManifest(TWO_STREAM_MANIFEST), onInteraction: async () => ({}), ownerToken, persistState: true, @@ -377,12 +395,14 @@ test("2.6: a portable RECORD/STATE/DONE-only connector yields a valid report wit const manifest = { connector_id: "creport-portable", display_name: "Portable Floor", + manifest_uri: "https://sources.example/creport-portable", protocol_version: "0.1.0", streams: [ { name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -405,7 +425,7 @@ test("2.6: a portable RECORD/STATE/DONE-only connector yields a valid report wit collectionMode: "full_refresh", connectorId, connectorPath, - manifest, + manifest: runtimeManifest(manifest), onInteraction: async () => ({}), ownerToken, persistState: true, @@ -457,7 +477,7 @@ test("2.5: collection_facts and collection_report are absent from grant-scoped / collectionMode: "full_refresh", connectorId, connectorPath, - manifest: TWO_STREAM_MANIFEST, + manifest: runtimeManifest(TWO_STREAM_MANIFEST), onInteraction: async () => ({}), ownerToken, persistState: true, @@ -533,7 +553,7 @@ test("derive-on-read: coverage condition is computed on each read (not frozen at collectionMode: "full_refresh", connectorId, connectorPath, - manifest: TWO_STREAM_MANIFEST, + manifest: runtimeManifest(TWO_STREAM_MANIFEST), onInteraction: async () => ({}), ownerToken, persistState: true, diff --git a/reference-implementation/test/composed-origin.test.ts b/reference-implementation/test/composed-origin.test.ts index 1b28feac4..148e02b28 100644 --- a/reference-implementation/test/composed-origin.test.ts +++ b/reference-implementation/test/composed-origin.test.ts @@ -862,9 +862,9 @@ test("composed browser origin carries metadata, owner session, console, device f access_mode: "single_use", purpose_code: "https://pdpp.dev/purpose/recommendation", purpose_description: "Review top artists", - retention: "P30D", + retention: { max_duration: "P30D", on_expiry: "delete" }, source: { id: SPOTIFY_CONNECTOR_ID, kind: "connector" }, - streams: [{ name: "top_artists" }], + streams: [{ instance_ids: [SPOTIFY_DEFAULT_CONNECTION_ID], name: "top_artists" }], type: "https://pdpp.dev/data-access", }, ], @@ -893,9 +893,25 @@ test("composed browser origin carries metadata, owner session, console, device f assert.match(consentHtml, TOP_REGEX_2); assert.ok(!consentHtml.includes(asUrl), "consent page should not leak the internal AS origin"); - const approvedGrant = await fetchJson(`${webOrigin}/consent/approve`, { + const reviewedGrant = await fetchJson(`${webOrigin}/consent/review`, { body: JSON.stringify({ request_uri: stagedRequestBody.request_uri }), headers: { + Accept: "application/json", + "Content-Type": "application/json", + Cookie: ownerCookie, + }, + method: "POST", + }); + assert.equal(reviewedGrant.resp.status, 200); + const reviewRevision = (reviewedGrant.body as { approval_review_revision?: unknown }).approval_review_revision; + assert.equal(typeof reviewRevision, "string", "consent review returns a revision"); + const approvedGrant = await fetchJson(`${webOrigin}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: reviewRevision, + request_uri: stagedRequestBody.request_uri, + }), + headers: { + Accept: "application/json", "Content-Type": "application/json", Cookie: ownerCookie, }, @@ -904,7 +920,7 @@ test("composed browser origin carries metadata, owner session, console, device f assert.equal(approvedGrant.resp.status, 200); const approvedGrantBody = approvedGrant.body as ApprovedGrantBody; assert.equal(typeof approvedGrantBody.token, "string"); - assert.deepEqual(approvedGrantBody.grant.source, { id: SPOTIFY_CONNECTOR_KEY, kind: "connector" }); + assert.deepEqual(approvedGrantBody.grant.source, { id: SPOTIFY_CONNECTOR_ID, kind: "connector" }); } finally { // Withdraw the explicit guard handoff granted in startWebServer once the // console child is gone, so the port is not implicitly trusted afterward. diff --git a/reference-implementation/test/connector-detail-default-account-and-ambiguity.test.ts b/reference-implementation/test/connector-detail-default-account-and-ambiguity.test.ts index a9d1d18bf..b7242a812 100644 --- a/reference-implementation/test/connector-detail-default-account-and-ambiguity.test.ts +++ b/reference-implementation/test/connector-detail-default-account-and-ambiguity.test.ts @@ -125,18 +125,31 @@ rl.on('line', (line) => { // the exact shape of a private/default (not catalog-browsable) connector, // which is what exposed the regression: catalog-visibility gating has no // legitimate role in an owner-addressed detail lookup. +const UNLISTED_CONNECTOR_KEY = "detail-default-account-unlisted"; +const UNLISTED_SOURCE_ID = `https://sources.example/connectors/${UNLISTED_CONNECTOR_KEY}`; +const UNLISTED_STREAMS = [ + { + name: "items", + primary_key: ["id"], + schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + semantics: "append_only", + }, +]; const UNLISTED_MANIFEST = { - connector_id: "detail-default-account-unlisted", + // The short connector_id is the local storage key; the Core SourceDeclaration + // carries the connector's stable public source identity separately. + connector_id: UNLISTED_CONNECTOR_KEY, display_name: "Unlisted Default-Account Connector", protocol_version: "0.1.0", - streams: [ - { - name: "items", - primary_key: ["id"], - schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, - semantics: "append_only", - }, - ], + source_declaration: { + declaration_version: "connector-detail-default-account-v1", + display: { name: "Unlisted Default-Account Connector" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation/tests" }, + source: { id: UNLISTED_SOURCE_ID, kind: "connector" }, + streams: UNLISTED_STREAMS, + }, + streams: UNLISTED_STREAMS, version: "1.0.0", }; @@ -185,11 +198,12 @@ test("a no-explicit-instance ingest run against an unlisted connector resolves t const { asPort, rsPort } = server; const asUrl = `http://localhost:${asPort}`; - await fetchJson(`${asUrl}/connectors`, { + const registration = await fetchJson(`${asUrl}/connectors`, { body: JSON.stringify(UNLISTED_MANIFEST), headers: { "Content-Type": "application/json" }, method: "POST", }); + assert.equal(registration.status, 201, "register connector fixture"); // The owner-dashboard read surface (`getConnectorDetail`) is hardcoded to // REFERENCE_OWNER_SUBJECT_ID/OWNER_AUTH_DEFAULT_SUBJECT_ID ('owner_local') // — a real, intentional single-owner security boundary. A run's owner @@ -256,11 +270,12 @@ test("a connector with zero real connections still resolves to the typed unresol const { asPort } = server; const asUrl = `http://localhost:${asPort}`; - await fetchJson(`${asUrl}/connectors`, { + const registration = await fetchJson(`${asUrl}/connectors`, { body: JSON.stringify(UNLISTED_MANIFEST), headers: { "Content-Type": "application/json" }, method: "POST", }); + assert.equal(registration.status, 201, "register connector fixture"); const connectorId = UNLISTED_MANIFEST.connector_id; try { diff --git a/reference-implementation/test/connector-failure-diagnostics-control-plane.test.ts b/reference-implementation/test/connector-failure-diagnostics-control-plane.test.ts index 971ed7d08..092d31e9d 100644 --- a/reference-implementation/test/connector-failure-diagnostics-control-plane.test.ts +++ b/reference-implementation/test/connector-failure-diagnostics-control-plane.test.ts @@ -51,17 +51,37 @@ const TEST_DCR_INITIAL_ACCESS_TOKEN = "pdpp-reference-test-initial-access-token" const STUB_MANIFEST = { connector_id: "https://registry.pdpp.dev/connectors/test-failure-diagnostics-cp", + display_name: "Failure diagnostics control-plane fixture", + manifest_uri: "https://registry.pdpp.dev/connectors/test-failure-diagnostics-cp", + protocol_version: "0.1.0", runtime_requirements: {}, streams: [ { name: "noop", - primary_key: "id", + primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "0.1.0", }; +type RuntimeManifest = Parameters<typeof runConnector>[0]["manifest"]; + +function runtimeManifest(manifest: { + streams: ReadonlyArray<{ name: string; selection?: unknown; [key: string]: unknown }>; + [key: string]: unknown; +}): RuntimeManifest { + return { + ...manifest, + streams: manifest.streams.map((stream) => { + const { selection: _selection, ...withoutSelection } = stream; + return withoutSelection; + }), + }; +} + // `startServer`'s inferred asServer/rsServer type comes from a framework // `.listen()` call whose TS overload resolves to an http2-shaped type, but at // runtime these are plain node:http/https servers (the framework never @@ -171,7 +191,7 @@ test("connector failure diagnostics surface on owner timeline; not on /v1 surfac collectionMode: "full_refresh", connectorId: STUB_MANIFEST.connector_id, connectorPath: stubPath, - manifest: STUB_MANIFEST, + manifest: runtimeManifest(STUB_MANIFEST), onInteraction: () => ({ status: "cancelled", type: "INTERACTION_RESPONSE" }), // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op test double represents an optional side effect. onProgress: () => {}, diff --git a/reference-implementation/test/connector-gap-severity.test.ts b/reference-implementation/test/connector-gap-severity.test.ts index 2fdaa493e..1a14d5f44 100644 --- a/reference-implementation/test/connector-gap-severity.test.ts +++ b/reference-implementation/test/connector-gap-severity.test.ts @@ -121,6 +121,7 @@ function makeManifest(connectorId = "https://registry.pdpp.test/connectors/gap-s required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, { @@ -137,6 +138,7 @@ function makeManifest(connectorId = "https://registry.pdpp.test/connectors/gap-s required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "mutable_state", }, ], @@ -144,6 +146,21 @@ function makeManifest(connectorId = "https://registry.pdpp.test/connectors/gap-s }; } +type RuntimeManifest = Parameters<typeof runConnector>[0]["manifest"]; + +function runtimeManifest(manifest: { + streams: ReadonlyArray<{ name: string; selection?: unknown; [key: string]: unknown }>; + [key: string]: unknown; +}): RuntimeManifest { + return { + ...manifest, + streams: manifest.streams.map((stream) => { + const { selection: _selection, ...withoutSelection } = stream; + return withoutSelection; + }), + }; +} + function createScopeAwareConnector( capturePath: string, { itemSkipReason = null }: { itemSkipReason?: string | null } = {} @@ -227,7 +244,7 @@ test("default START.scope excludes unsupported-in-mode streams", async () => { collectionMode: "incremental", connectorId: manifest.connector_id, connectorPath, - manifest, + manifest: runtimeManifest(manifest), onInteraction: async () => ({}), ownerToken, persistState: true, @@ -259,7 +276,7 @@ test("explicit unsupported-in-mode stream skip is actionable", async () => { collectionMode: "incremental", connectorId: manifest.connector_id, connectorPath, - manifest, + manifest: runtimeManifest(manifest), onInteraction: async () => ({}), ownerToken, persistState: true, @@ -293,7 +310,7 @@ test("default-selected supported stream not_available stays actionable", async ( collectionMode: "incremental", connectorId: manifest.connector_id, connectorPath, - manifest, + manifest: runtimeManifest(manifest), onInteraction: async () => ({}), ownerToken, persistState: true, @@ -326,7 +343,7 @@ test("transient skip reasons are persisted with transient severity", async () => collectionMode: "incremental", connectorId: manifest.connector_id, connectorPath, - manifest, + manifest: runtimeManifest(manifest), onInteraction: async () => ({}), ownerToken, persistState: true, diff --git a/reference-implementation/test/connector-instance-record-ingest-admission.test.ts b/reference-implementation/test/connector-instance-record-ingest-admission.test.ts index e853c15e6..8429db4e8 100644 --- a/reference-implementation/test/connector-instance-record-ingest-admission.test.ts +++ b/reference-implementation/test/connector-instance-record-ingest-admission.test.ts @@ -25,6 +25,7 @@ import { import { dedicatedPostgresTestUrl } from "./helpers/dedicated-postgres-test-url.ts"; const CONNECTOR_ID = "record_ingest_admission_probe"; +const CONNECTOR_URI = "https://registry.pdpp.dev/connectors/record-ingest-admission-probe"; const DEDICATED_POSTGRES_URL = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); const NOW = "2026-08-13T00:00:00.000Z"; const STREAM = "events"; @@ -47,7 +48,9 @@ function manifest() { return { capabilities: { human_interaction: [] }, connector_id: CONNECTOR_ID, + connector_key: CONNECTOR_ID, display_name: "Record ingest admission probe", + manifest_uri: CONNECTOR_URI, protocol_version: "0.1.0", streams: [ { @@ -58,6 +61,8 @@ function manifest() { required: ["id", "value"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", diff --git a/reference-implementation/test/connector-instances-acceptance.test.ts b/reference-implementation/test/connector-instances-acceptance.test.ts index f8018c101..d934c9fed 100644 --- a/reference-implementation/test/connector-instances-acceptance.test.ts +++ b/reference-implementation/test/connector-instances-acceptance.test.ts @@ -33,6 +33,7 @@ function manifest(connectorId: string, stream = "messages") { return { connector_id: connectorId, display_name: connectorId, + manifest_uri: `https://sources.example/${connectorId}`, protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ @@ -47,6 +48,7 @@ function manifest(connectorId: string, stream = "messages") { required: ["id", "subject"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "mutable_state", }, ], diff --git a/reference-implementation/test/connector-run-evidence-policy.test.ts b/reference-implementation/test/connector-run-evidence-policy.test.ts index 664e13f45..cf8e592e9 100644 --- a/reference-implementation/test/connector-run-evidence-policy.test.ts +++ b/reference-implementation/test/connector-run-evidence-policy.test.ts @@ -6,8 +6,8 @@ * projection helpers in `server/connector-run-evidence.ts`. No test imports * this module by name. * - * - getConnectorRunEvidenceSource (returns the id only for a connector-kind - * source with a non-empty string id) + * - getConnectorRunEvidenceConnectorId (returns the id only from a trusted + * storage binding with a non-empty connector id) * - getManifestRefreshPolicy (reads capabilities.refresh_policy, with a * strict object guard on capabilities) * - getMaximumStalenessSeconds (accepts a positive finite number only; @@ -23,23 +23,21 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - getConnectorRunEvidenceSource, + getConnectorRunEvidenceConnectorId, getManifestRefreshPolicy, getMaximumStalenessSeconds, } from "../server/connector-run-evidence.ts"; -test("getConnectorRunEvidenceSource: connector-kind + non-empty string id -> id, else null", () => { - assert.equal(getConnectorRunEvidenceSource({ id: "github", kind: "connector" }), "github"); +test("getConnectorRunEvidenceConnectorId: storage connector id + non-empty string id -> id, else null", () => { + assert.equal(getConnectorRunEvidenceConnectorId({ connector_id: "github" }), "github"); - // Wrong kind -> null. - assert.equal(getConnectorRunEvidenceSource({ id: "plaid", kind: "provider_native" }), null); // Missing / empty / non-string id -> null. - assert.equal(getConnectorRunEvidenceSource({ id: "", kind: "connector" }), null); - assert.equal(getConnectorRunEvidenceSource({ kind: "connector" }), null); - assert.equal(getConnectorRunEvidenceSource({ id: 42, kind: "connector" }), null); - // Null-ish source -> null (optional-chaining guard). - assert.equal(getConnectorRunEvidenceSource(null), null); - assert.equal(getConnectorRunEvidenceSource(undefined), null); + assert.equal(getConnectorRunEvidenceConnectorId({ connector_id: "" }), null); + assert.equal(getConnectorRunEvidenceConnectorId({}), null); + assert.equal(getConnectorRunEvidenceConnectorId({ connector_id: 42 }), null); + // Null-ish storage binding -> null (optional-chaining guard). + assert.equal(getConnectorRunEvidenceConnectorId(null), null); + assert.equal(getConnectorRunEvidenceConnectorId(undefined), null); }); test("getManifestRefreshPolicy: reads capabilities.refresh_policy behind a strict object guard", () => { diff --git a/reference-implementation/test/connector-run-evidence-pure.test.ts b/reference-implementation/test/connector-run-evidence-pure.test.ts index 26dcc8ad7..e903e7b70 100644 --- a/reference-implementation/test/connector-run-evidence-pure.test.ts +++ b/reference-implementation/test/connector-run-evidence-pure.test.ts @@ -3,13 +3,13 @@ // Pure, no-DB unit tests for the pure exports of server/connector-run-evidence.ts. // No test imports this module by name. These extract the connector-run evidence -// source id and the manifest refresh-policy / staleness bound used by schema + +// storage connector id and the manifest refresh-policy / staleness bound used by schema + // freshness projection and scheduler admission. (getLatestConnectorRunSummary is // async spine-backed and out of scope here.) // // Mutation surface: -// getConnectorRunEvidenceSource -- connector-kind + non-empty string id -> id, -// else null (provider_native / empty id / missing -> null). +// getConnectorRunEvidenceConnectorId -- trusted storage connector id -> id, +// else null (empty / missing / non-string -> null). // getManifestRefreshPolicy -- capabilities must be a plain object, else null; // returns capabilities.refresh_policy ?? null. // getMaximumStalenessSeconds -- a positive finite number -> value, else null @@ -19,29 +19,24 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - getConnectorRunEvidenceSource, + getConnectorRunEvidenceConnectorId, getManifestRefreshPolicy, getMaximumStalenessSeconds, } from "../server/connector-run-evidence.ts"; // --------------------------------------------------------------------------- -// getConnectorRunEvidenceSource +// getConnectorRunEvidenceConnectorId // --------------------------------------------------------------------------- -test("getConnectorRunEvidenceSource: connector source with a non-empty id yields the id", () => { - assert.equal(getConnectorRunEvidenceSource({ id: "amazon", kind: "connector" }), "amazon"); +test("getConnectorRunEvidenceConnectorId: a storage binding with a non-empty connector_id yields the id", () => { + assert.equal(getConnectorRunEvidenceConnectorId({ connector_id: "amazon" }), "amazon"); }); -test("getConnectorRunEvidenceSource: provider_native, empty id, or missing source -> null", () => { - assert.equal( - getConnectorRunEvidenceSource({ id: "gmail", kind: "provider_native" }), - null, - "native is not a connector run source" - ); - assert.equal(getConnectorRunEvidenceSource({ id: "", kind: "connector" }), null, "empty id -> null"); - assert.equal(getConnectorRunEvidenceSource({ kind: "connector" }), null, "missing id -> null"); - assert.equal(getConnectorRunEvidenceSource(null), null); - assert.equal(getConnectorRunEvidenceSource({ id: 42, kind: "connector" }), null, "non-string id -> null"); +test("getConnectorRunEvidenceConnectorId: empty, missing, or non-string connector_id -> null", () => { + assert.equal(getConnectorRunEvidenceConnectorId({ connector_id: "" }), null, "empty id -> null"); + assert.equal(getConnectorRunEvidenceConnectorId({}), null, "missing id -> null"); + assert.equal(getConnectorRunEvidenceConnectorId(null), null); + assert.equal(getConnectorRunEvidenceConnectorId({ connector_id: 42 }), null, "non-string id -> null"); }); // --------------------------------------------------------------------------- diff --git a/reference-implementation/test/connector-run-evidence.test.ts b/reference-implementation/test/connector-run-evidence.test.ts index 93e7ba3ba..c165fd6e5 100644 --- a/reference-implementation/test/connector-run-evidence.test.ts +++ b/reference-implementation/test/connector-run-evidence.test.ts @@ -7,7 +7,7 @@ * connector-run-evidence.js has no co-named test. The async * getLatestConnectorRunSummary needs the spine store and is out of scope * here; these tests pin the three pure, synchronous projections directly: - * - getConnectorRunEvidenceSource: connector-source id gating, + * - getConnectorRunEvidenceConnectorId: storage connector id gating, * - getManifestRefreshPolicy: capabilities shape gating, * - getMaximumStalenessSeconds: positive-finite-number gating. */ @@ -16,17 +16,16 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - getConnectorRunEvidenceSource, + getConnectorRunEvidenceConnectorId, getManifestRefreshPolicy, getMaximumStalenessSeconds, } from "../server/connector-run-evidence.ts"; -test("getConnectorRunEvidenceSource returns the id only for a connector source", () => { - assert.equal(getConnectorRunEvidenceSource({ id: "gmail", kind: "connector" }), "gmail"); - assert.equal(getConnectorRunEvidenceSource({ id: "apple", kind: "provider_native" }), null); - assert.equal(getConnectorRunEvidenceSource({ id: "", kind: "connector" }), null); - assert.equal(getConnectorRunEvidenceSource({ kind: "connector" }), null); - assert.equal(getConnectorRunEvidenceSource(null), null); +test("getConnectorRunEvidenceConnectorId returns the id only from a storage binding", () => { + assert.equal(getConnectorRunEvidenceConnectorId({ connector_id: "gmail" }), "gmail"); + assert.equal(getConnectorRunEvidenceConnectorId({ connector_id: "" }), null); + assert.equal(getConnectorRunEvidenceConnectorId({}), null); + assert.equal(getConnectorRunEvidenceConnectorId(null), null); }); test("getManifestRefreshPolicy reads capabilities.refresh_policy or null", () => { diff --git a/reference-implementation/test/connector-summary-evidence-throughput-integration.test.ts b/reference-implementation/test/connector-summary-evidence-throughput-integration.test.ts index cf25abd2d..008fc3acb 100644 --- a/reference-implementation/test/connector-summary-evidence-throughput-integration.test.ts +++ b/reference-implementation/test/connector-summary-evidence-throughput-integration.test.ts @@ -82,6 +82,8 @@ function manifestFor(connectorKey: string, streams: readonly string[]) { name, primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", })), version: "1.0.0", }; diff --git a/reference-implementation/test/consent-device-auth-conformance-falsifiability.test.ts b/reference-implementation/test/consent-device-auth-conformance-falsifiability.test.ts index b6a07132a..606ea56c4 100644 --- a/reference-implementation/test/consent-device-auth-conformance-falsifiability.test.ts +++ b/reference-implementation/test/consent-device-auth-conformance-falsifiability.test.ts @@ -87,7 +87,7 @@ test("harness detects at least one consent/device-auth invariant violation in a const terminalApproveFailed = failures.some((f) => /pending consent: approval is terminal/.test(f.name)); assert.ok( terminalApproveFailed, - `expected the pending-consent terminal-approval scenario to fail. failures=${JSON.stringify( + `expected the pending-consent durable-resume scenario to fail. failures=${JSON.stringify( failures.map((f) => f.name), null, 2 diff --git a/reference-implementation/test/consent-device-auth-conformance-production-store.test.ts b/reference-implementation/test/consent-device-auth-conformance-production-store.test.ts index 23d1fb386..4eab4d056 100644 --- a/reference-implementation/test/consent-device-auth-conformance-production-store.test.ts +++ b/reference-implementation/test/consent-device-auth-conformance-production-store.test.ts @@ -22,11 +22,36 @@ import test from "node:test"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; import { runConsentDeviceAuthConformance } from "./helpers/consent-device-auth-conformance.ts"; import { createProductionConsentDeviceAuthDriver } from "./helpers/production-consent-device-auth-driver.ts"; runConsentDeviceAuthConformance({ label: "production-store", - makeDriver: () => createProductionConsentDeviceAuthDriver(), + makeDriver: () => { + const driver = createProductionConsentDeviceAuthDriver(); + return { + ...driver, + async setup() { + await driver.setup(); + const connectorId = driver.getRegisteredConnectorId(); + const canonicalId = canonicalConnectorKey(connectorId) ?? connectorId; + const now = new Date().toISOString(); + await createSqliteConnectorInstanceStore().upsert({ + connectorId: canonicalId, + connectorInstanceId: "cin_conformance_spotify", + createdAt: now, + displayName: "Spotify", + ownerSubjectId: "owner_local", + sourceBinding: { kind: "test_account", label: "consent-conformance-spotify" }, + sourceBindingKey: "consent-conformance-spotify", + sourceKind: "account", + status: "active", + updatedAt: now, + }); + }, + }; + }, test, }); diff --git a/reference-implementation/test/consent-device-auth-conformance.test.ts b/reference-implementation/test/consent-device-auth-conformance.test.ts index 1dc8147eb..5f175cd33 100644 --- a/reference-implementation/test/consent-device-auth-conformance.test.ts +++ b/reference-implementation/test/consent-device-auth-conformance.test.ts @@ -25,6 +25,8 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; import { runConsentDeviceAuthConformance } from "./helpers/consent-device-auth-conformance.ts"; import { createSqliteConsentDeviceAuthDriver } from "./helpers/sqlite-consent-device-auth-driver.ts"; @@ -134,6 +136,24 @@ runConsentDeviceAuthConformance({ ...(typeof view.expires_at === "string" || view.expires_at === null ? { expires_at: view.expires_at } : {}), }; }, + async setup() { + await driver.setup(); + const connectorId = driver.getRegisteredConnectorId(); + const canonicalId = canonicalConnectorKey(connectorId) ?? connectorId; + const now = new Date().toISOString(); + await createSqliteConnectorInstanceStore().upsert({ + connectorId: canonicalId, + connectorInstanceId: "cin_conformance_spotify", + createdAt: now, + displayName: "Spotify", + ownerSubjectId: "owner_local", + sourceBinding: { kind: "test_account", label: "consent-conformance-spotify" }, + sourceBindingKey: "consent-conformance-spotify", + sourceKind: "account", + status: "active", + updatedAt: now, + }); + }, async startOwnerDeviceAuth(input: Record<string, unknown>) { const clientId = stringProperty(input, "client_id"); const interval = numberProperty(input, "interval"); diff --git a/reference-implementation/test/control-actions.test.ts b/reference-implementation/test/control-actions.test.ts index 9ba7fd5b4..797243f54 100644 --- a/reference-implementation/test/control-actions.test.ts +++ b/reference-implementation/test/control-actions.test.ts @@ -1227,12 +1227,14 @@ test("schedule upsert returns policy_warning when interval is below minimum_inte }, connector_id: "policy-warning-test", display_name: "Policy Warning Test", + manifest_uri: "https://sources.example/policy-warning-test", protocol_version: "0.1.0", streams: [ { name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1286,12 +1288,14 @@ test("schedule upsert rejects enabling manual or background-unsafe connector pol }, connector_id: "manual-unsafe-test", display_name: "Manual Unsafe Test", + manifest_uri: "https://sources.example/manual-unsafe-test", protocol_version: "0.1.0", streams: [ { name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1329,12 +1333,14 @@ test("schedule upsert permits a manual-default connector when background_safe=tr }, connector_id: "manual-safe-test", display_name: "Manual Safe Test", + manifest_uri: "https://sources.example/manual-safe-test", protocol_version: "0.1.0", streams: [ { name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1381,12 +1387,14 @@ test("schedule upsert permits assisted-after-owner-auth schedules as unattended }, connector_id: "assisted-after-owner-auth-test", display_name: "Assisted After Owner Auth Test", + manifest_uri: "https://sources.example/assisted-after-owner-auth-test", protocol_version: "0.1.0", streams: [ { name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1426,12 +1434,14 @@ test("schedule resume rejects a disabled schedule when connector policy is backg }, connector_id: "background-unsafe-test", display_name: "Background Unsafe Test", + manifest_uri: "https://sources.example/background-unsafe-test", protocol_version: "0.1.0", streams: [ { name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1484,12 +1494,14 @@ test("GET /_ref/schedules surfaces ineligibility_reason for a stale enabled row }, connector_id: connectorId, display_name: "Stale Unsafe Reconcile Test", + manifest_uri: `https://sources.example/${connectorId}`, protocol_version: "0.1.0", streams: [ { name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1576,12 +1588,14 @@ test("GET /_ref/schedules omits ineligibility_reason when persisted row is disab }, connector_id: safeId, display_name: "Eligible Schedule Listing Test", + manifest_uri: `https://sources.example/${safeId}`, protocol_version: "0.1.0", streams: [ { name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1600,12 +1614,14 @@ test("GET /_ref/schedules omits ineligibility_reason when persisted row is disab }, connector_id: disabledId, display_name: "Disabled Unsafe Listing Test", + manifest_uri: `https://sources.example/${disabledId}`, protocol_version: "0.1.0", streams: [ { name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1685,7 +1701,7 @@ test("GET /_ref/approvals surfaces pending provider-connect consents with grant assert.ok(entry, "expected a consent approval entry"); assert.equal(entry.client_id, "concert_recommendation_app"); assert.ok(entry.grant_preview); - assert.deepEqual(entry.grant_preview.source, { id: SPOTIFY_CONNECTOR_KEY, kind: "connector" }); + assert.deepEqual(entry.grant_preview.source, { id: spotifyManifest.connector_id, kind: "connector" }); assert.equal(entry.grant_preview.access_mode, "single_use"); assert.ok(Array.isArray(entry.grant_preview.streams)); }); diff --git a/reference-implementation/test/core-only-source-runtime-journey.test.ts b/reference-implementation/test/core-only-source-runtime-journey.test.ts new file mode 100644 index 000000000..51eb4e427 --- /dev/null +++ b/reference-implementation/test/core-only-source-runtime-journey.test.ts @@ -0,0 +1,248 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CoreSourceAuthorizationError, + createRetainedCoreConsentSnapshot, + materializeCoreResolvedGrant, + readRetainedCoreConsentSnapshot, + renderRetainedCoreConsent, + resolveCoreEligibleInstanceIds, + servePrecollectedCoreRecords, + validateCoreSelectionRequest, +} from "../server/core-source-authorization.ts"; + +const SOURCE = { id: "https://sources.example/core/github", kind: "connector" } as const; +const INSTANCE_A = "opaque-github-account-a"; +const INSTANCE_B = "opaque-github-account-b"; +const NOT_DERIVABLE_RE = /not derivable/; + +function declaration() { + return { + declaration_version: "github-core-v1", + display: { name: "GitHub" }, + extensions: {}, + protocol_version: "0.1.0", + publisher: { id: "https://publishers.example/github" }, + source: SOURCE, + streams: [ + { + consent_time_field: "updated_at", + name: "issues", + primary_key: ["id"], + schema: { + properties: { + id: { type: "string" }, + private_note: { type: "string" }, + title: { type: "string" }, + updated_at: { format: "date-time", type: "string" }, + }, + required: ["id", "updated_at"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + ], + }; +} + +function selection() { + return { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/research", + streams: [ + { + fields: ["title"], + instance_ids: [INSTANCE_A], + name: "issues", + resources: ["issue-1"], + time_range: { since: "2026-01-01T00:00:00Z" }, + }, + ], + type: "https://pdpp.dev/data-access", + }; +} + +test("Core-only connector validates, renders retained consent, issues a grant, and serves pre-collected records", () => { + let liveDeclaration: ReturnType<typeof declaration> | null = declaration(); + const requestSelection = validateCoreSelectionRequest({ ...selection(), source: SOURCE }); + const snapshot = createRetainedCoreConsentSnapshot({ + declaration: liveDeclaration, + selection: requestSelection, + source: requestSelection.source, + sourceSensitivity: "sensitive", + }); + + const [liveStream] = liveDeclaration.streams; + assert.ok(liveStream); + liveStream.schema.properties = { + replacement_only: { type: "string" }, + } as unknown as typeof liveStream.schema.properties; + liveDeclaration = null; + assert.equal(liveDeclaration, null); + + const consent = renderRetainedCoreConsent({ + selection: requestSelection, + snapshot, + source: requestSelection.source, + }); + assert.deepEqual(consent.display, { name: "GitHub" }); + assert.deepEqual(consent.resolvedStreams, [ + { + fields: ["title", "id", "updated_at"], + instance_ids: [INSTANCE_A], + name: "issues", + resources: ["issue-1"], + time_constraint: { field: "updated_at", since: "2026-01-01T00:00:00Z" }, + }, + ]); + + const eligibleStreams = resolveCoreEligibleInstanceIds({ + eligibleInstanceIdsByStream: { issues: [INSTANCE_A] }, + streams: consent.resolvedStreams, + }); + assert.throws( + () => + resolveCoreEligibleInstanceIds({ + eligibleInstanceIdsByStream: { issues: [INSTANCE_B] }, + streams: consent.resolvedStreams, + }), + (error: unknown) => + error instanceof CoreSourceAuthorizationError && error.code === "source.authorization_details_invalid" + ); + + const grant = materializeCoreResolvedGrant({ + accessMode: requestSelection.access_mode, + clientId: "research-app", + expiresAt: null, + grantId: "grant-core-1", + issuedAt: "2026-08-11T12:00:00Z", + purposeCode: requestSelection.purpose_code, + resolvedStreams: eligibleStreams, + snapshot, + subjectId: "owner-1", + }); + assert.deepEqual(grant.source, SOURCE); + assert.equal(grant.source_declaration.version, "github-core-v1"); + + const served = servePrecollectedCoreRecords({ + grant, + instanceId: INSTANCE_A, + records: [ + { + data: { + id: "issue-1", + private_note: "must not be disclosed", + title: "Visible issue", + updated_at: "2026-02-01T00:00:00Z", + }, + instance_id: INSTANCE_A, + key: "issue-1", + stream: "issues", + }, + { + data: { id: "issue-2", title: "Wrong resource", updated_at: "2026-02-01T00:00:00Z" }, + instance_id: INSTANCE_A, + key: "issue-2", + stream: "issues", + }, + { + data: { id: "issue-1", title: "Too old", updated_at: "2025-12-01T00:00:00Z" }, + instance_id: INSTANCE_A, + key: "issue-1", + stream: "issues", + }, + { + data: { id: "issue-1", title: "Wrong instance", updated_at: "2026-02-01T00:00:00Z" }, + instance_id: INSTANCE_B, + key: "issue-1", + stream: "issues", + }, + ], + stream: "issues", + }); + assert.deepEqual(served, [ + { + data: { id: "issue-1", title: "Visible issue", updated_at: "2026-02-01T00:00:00Z" }, + key: "issue-1", + stream: "issues", + }, + ]); +}); + +test("Core selection failures use the binding-neutral Source error", () => { + assert.throws( + () => + createRetainedCoreConsentSnapshot({ + declaration: declaration(), + selection: { + ...selection(), + streams: [{ fields: ["missing"], instance_ids: [INSTANCE_A], name: "issues" }], + }, + source: SOURCE, + sourceSensitivity: "sensitive", + }), + (error: unknown) => + error instanceof CoreSourceAuthorizationError && error.code === "source.authorization_details_invalid" + ); +}); + +test("Core retained consent rejects malformed resolved streams and sensitivity with the neutral Source error", () => { + const requestSelection = validateCoreSelectionRequest({ ...selection(), source: SOURCE }); + const snapshot = createRetainedCoreConsentSnapshot({ + declaration: declaration(), + selection: requestSelection, + source: SOURCE, + sourceSensitivity: "sensitive", + }); + for (const tampered of [ + { ...snapshot, resolved_streams: null }, + { ...snapshot, source_sensitivity: "" }, + ]) { + assert.throws( + () => + readRetainedCoreConsentSnapshot({ + selection: requestSelection, + snapshot: tampered, + source: SOURCE, + }), + (error: unknown) => + error instanceof CoreSourceAuthorizationError && error.code === "source.authorization_details_invalid" + ); + } +}); + +test("Core retained consent survives declaration object-key reordering but not resolved array drift", () => { + const requestSelection = validateCoreSelectionRequest({ + ...selection(), + source: SOURCE, + streams: [{ instance_ids: [INSTANCE_A], name: "issues" }], + }); + const snapshot = createRetainedCoreConsentSnapshot({ + declaration: declaration(), + selection: requestSelection, + source: SOURCE, + sourceSensitivity: "sensitive", + }); + const roundTripped = JSON.parse(JSON.stringify(snapshot)) as typeof snapshot; + const [roundTrippedStream] = roundTripped.declaration.streams; + const properties = roundTrippedStream?.schema.properties; + assert.ok(properties); + assert.ok(roundTrippedStream); + roundTrippedStream.schema.properties = Object.fromEntries(Object.entries(properties).reverse()); + assert.doesNotThrow(() => + readRetainedCoreConsentSnapshot({ selection: requestSelection, snapshot: roundTripped, source: SOURCE }) + ); + + const changedArray = structuredClone(roundTripped); + const [changedStream] = changedArray.resolved_streams; + assert.ok(changedStream); + changedStream.fields?.reverse(); + assert.throws( + () => readRetainedCoreConsentSnapshot({ selection: requestSelection, snapshot: changedArray, source: SOURCE }), + NOT_DERIVABLE_RE + ); +}); diff --git a/reference-implementation/test/core-only-source-runtime.test.ts b/reference-implementation/test/core-only-source-runtime.test.ts new file mode 100644 index 000000000..a5e00f617 --- /dev/null +++ b/reference-implementation/test/core-only-source-runtime.test.ts @@ -0,0 +1,37 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const CORE_JOURNEY_NAME = /Core-only connector validates, renders retained consent, issues a grant/; +const FORBIDDEN_IMPORT_ERROR = /Core-only runtime loaded a Collection module/; + +test("Core-only SourceDeclaration consent and read do not load the legacy Collection projection", () => { + const loaderPath = fileURLToPath(new URL("./fixtures/forbid-legacy-collection-loader.mjs", import.meta.url)); + const journeyPath = fileURLToPath(new URL("./core-only-source-runtime-journey.test.ts", import.meta.url)); + const childEnv = { ...process.env, NODE_TEST_CONTEXT: undefined, PDPP_TEST_POSTGRES_URL: "" }; + const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + "--experimental-loader", + loaderPath, + "--test", + "--test-name-pattern=Core-only connector validates, renders retained consent, issues a grant", + journeyPath, + ], + { + encoding: "utf8", + env: childEnv, + timeout: 60_000, + } + ); + + assert.equal(result.status, 0, `Core-only journey failed.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.match(result.stdout, CORE_JOURNEY_NAME); + assert.doesNotMatch(result.stderr, FORBIDDEN_IMPORT_ERROR); +}); diff --git a/reference-implementation/test/dcr-per-owner-token.test.ts b/reference-implementation/test/dcr-per-owner-token.test.ts index 54bf1ec17..26ddadb1d 100644 --- a/reference-implementation/test/dcr-per-owner-token.test.ts +++ b/reference-implementation/test/dcr-per-owner-token.test.ts @@ -6,6 +6,8 @@ import test from "node:test"; import { getDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; +import { introspectionHeaders } from "./helpers/introspection.ts"; +import { TEST_INTROSPECTION_SERVER_OPTS } from "./helpers/introspection-test-credentials.ts"; const REGEXP_1 = /<input type="hidden" name="_csrf" value="([^"]+)"\s*\/>/; @@ -31,9 +33,11 @@ interface StartServerOptions { asPort?: number; dbPath?: string; dynamicClientRegistrationInitialAccessTokens?: string[]; + introspectionCallerCredentials?: unknown; ownerAuthPassword?: string; ownerAuthSubjectId?: string; quiet?: boolean; + rsIntrospectionCredentials?: unknown; rsPort?: number; } @@ -150,6 +154,7 @@ async function withServer(fn: (ctx: { asUrl: string }) => Promise<void>): Promis ownerAuthSubjectId: TEST_SUBJECT, quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); try { await fn({ asUrl: `http://localhost:${server.asPort}` }); @@ -270,7 +275,7 @@ async function issueOwnerTokenViaDeviceFlow(asUrl: string, clientId: string, coo device_code: device.device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code", }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); assert.equal(tokenResp.status, 200); @@ -280,7 +285,7 @@ async function issueOwnerTokenViaDeviceFlow(asUrl: string, clientId: string, coo async function introspect(asUrl: string, token: string): Promise<IntrospectBody> { const resp = await fetch(`${asUrl}/introspect`, { body: JSON.stringify({ token }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); assert.equal(resp.status, 200); @@ -323,10 +328,11 @@ function seedActiveHostedMcpPackageForClient(clientId: string): SeededPackageSta db.prepare(` INSERT INTO oauth_refresh_tokens( - refresh_token_hash, client_id, grant_id, package_id, subject_id, status, - created_at, expires_at, last_used_at, revoked_at - ) VALUES (?, ?, NULL, ?, ?, 'active', ?, NULL, NULL, NULL) - `).run(refreshTokenHash, clientId, packageId, TEST_SUBJECT, now); + refresh_token_hash, family_id, generation, parent_generation, + client_id, grant_id, package_id, subject_id, status, + created_at, expires_at, last_used_at, superseded_at, revoked_at + ) VALUES (?, ?, 0, NULL, ?, NULL, ?, ?, 'active', ?, NULL, NULL, NULL, NULL) + `).run(refreshTokenHash, "rtf_dcr_delete_cascade", clientId, packageId, TEST_SUBJECT, now); return { packageId, packageTokenId, refreshTokenHash }; } diff --git a/reference-implementation/test/detail-coverage-recovered-gap-regression.test.ts b/reference-implementation/test/detail-coverage-recovered-gap-regression.test.ts index 046fe0e11..790cf63c1 100644 --- a/reference-implementation/test/detail-coverage-recovered-gap-regression.test.ts +++ b/reference-implementation/test/detail-coverage-recovered-gap-regression.test.ts @@ -224,12 +224,14 @@ async function issueOwnerToken(asUrl: string): Promise<string> { const MANIFEST = { connector_id: "chatgpt-recovered-regression", display_name: "ChatGPT Recovered-Gap Regression", + manifest_uri: "https://sources.example/chatgpt-recovered-regression", protocol_version: "0.1.0", streams: [ { name: "conversations", primary_key: ["id"], schema: { properties: { id: { type: "string" }, title: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, { @@ -240,12 +242,28 @@ const MANIFEST = { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], version: "1.0.0", }; +type RuntimeManifest = Parameters<typeof runConnector>[0]["manifest"]; + +function runtimeManifest(manifest: { + streams: ReadonlyArray<{ name: string; selection?: unknown; [key: string]: unknown }>; + [key: string]: unknown; +}): RuntimeManifest { + return { + ...manifest, + streams: manifest.streams.map((stream) => { + const { selection: _selection, ...withoutSelection } = stream; + return withoutSelection; + }), + }; +} + function createCannedConnector(messages: readonly Record<string, unknown>[]): { connectorPath: string; cleanup: () => void; @@ -346,7 +364,7 @@ test("a recovered detail gap re-deferred with the same identity must not fail th connectorId, connectorPath, detailGapStore: store, - manifest: MANIFEST, + manifest: runtimeManifest(MANIFEST), onInteraction: async () => ({}), ownerToken, persistState: true, @@ -449,7 +467,7 @@ test("run.detail_gap_recorded fires once at first sighting, NOT on a prior-run r connectorId, connectorPath, detailGapStore: store, - manifest: MANIFEST, + manifest: runtimeManifest(MANIFEST), onInteraction: async () => ({}), ownerToken, persistState: true, @@ -583,7 +601,7 @@ test("a recovered gap re-deferred by a LATER run reopens to pending and surfaces connectorId, connectorPath, detailGapStore: store, - manifest: MANIFEST, + manifest: runtimeManifest(MANIFEST), onInteraction: async () => ({}), ownerToken, persistState: true, @@ -706,7 +724,7 @@ test("a recovered gap re-deferred by the SAME run that recovered it stays recove connectorId, connectorPath, detailGapStore: store, - manifest: MANIFEST, + manifest: runtimeManifest(MANIFEST), onInteraction: async () => ({}), ownerToken, persistState: true, @@ -782,7 +800,7 @@ test("a truly pending gap still surfaces as a retryable known_gap", async (t) => connectorId, connectorPath, detailGapStore: store, - manifest: MANIFEST, + manifest: runtimeManifest(MANIFEST), onInteraction: async () => ({}), ownerToken, persistState: true, diff --git a/reference-implementation/test/error-code-query-not-found.test.ts b/reference-implementation/test/error-code-query-not-found.test.ts index 9a58860cc..158448ea1 100644 --- a/reference-implementation/test/error-code-query-not-found.test.ts +++ b/reference-implementation/test/error-code-query-not-found.test.ts @@ -58,6 +58,9 @@ function hasCodeAndHttpStatus(err: unknown): err is { code: unknown; httpStatus: const MANIFEST = { connector_id: CONNECTOR_ID, + display_name: "Query not-found fixture", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, + protocol_version: "0.1.0", streams: [ { consent_time_field: "created_at", @@ -73,7 +76,8 @@ const MANIFEST = { required: ["id"], type: "object", }, - selection: { fields: true }, + selection: { fields: true, resources: true }, + semantics: "append_only", }, ], version: "1.0.0", diff --git a/reference-implementation/test/error-code-status-table-exhaustive.test.ts b/reference-implementation/test/error-code-status-table-exhaustive.test.ts index ab39c87c3..9cc0699ad 100644 --- a/reference-implementation/test/error-code-status-table-exhaustive.test.ts +++ b/reference-implementation/test/error-code-status-table-exhaustive.test.ts @@ -35,6 +35,7 @@ const EXPECTED_CODE_TO_STATUS = { ambiguous_connection: 409, ambiguous_connector_instance: 400, ambiguous_schema_detail: 409, + approval_conflict: 409, authentication_error: 401, blob_not_found: 404, browser_enrollment_shell_required: 400, @@ -63,6 +64,7 @@ const EXPECTED_CODE_TO_STATUS = { insufficient_scope: 403, interaction_id_mismatch: 409, invalid_argument: 400, + invalid_authorization_details: 400, invalid_client: 400, invalid_client_metadata: 400, invalid_cursor: 400, @@ -86,6 +88,8 @@ const EXPECTED_CODE_TO_STATUS = { run_owner_mismatch: 403, run_terminal: 503, source_webhook_event_conflict: 409, + "source.authorization_details_invalid": 400, + stream_not_declared: 404, unknown_field: 400, unsupported_version: 400, }; diff --git a/reference-implementation/test/event-spine.test.ts b/reference-implementation/test/event-spine.test.ts index 254e44176..41984839f 100644 --- a/reference-implementation/test/event-spine.test.ts +++ b/reference-implementation/test/event-spine.test.ts @@ -123,6 +123,29 @@ interface ConsentApprovalBody { grant: { grant_id: string }; token: string; } + +async function approveReviewedConsent(asUrl: string, requestUri: string, subjectId: string): Promise<Response> { + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewBody = (await reviewResp.json()) as { + approval_review?: unknown; + approval_review_revision?: unknown; + }; + assert.equal(reviewResp.status, 200, JSON.stringify(reviewBody)); + assert.ok(reviewBody.approval_review && typeof reviewBody.approval_review === "object"); + assert.equal(typeof reviewBody.approval_review_revision, "string"); + return fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: reviewBody.approval_review_revision, + request_uri: requestUri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); +} interface StreamRecordsBody { data: unknown[]; } @@ -752,11 +775,7 @@ test("event spine", async (t) => { assert.ok(initiateTraceId?.startsWith("trc_")); const initiate = (await initiateResp.json()) as ParInitiateBody; - const approveResp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: "u1" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); + const approveResp = await approveReviewedConsent(asUrl, initiate.request_uri, "u1"); assert.equal(approveResp.status, 200); const approval = (await approveResp.json()) as ConsentApprovalBody; @@ -824,7 +843,7 @@ test("event spine", async (t) => { const event = (traceTimeline.data || []).find((entry) => entry.event_type === eventType); assert.ok(event, `expected ${eventType} event`); assert.equal(event.data?.source?.kind, "connector"); - assert.equal(event.data?.source?.id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(event.data?.source?.id, spotifyManifest.connector_id); assert.ok( !("connector_id" in (event.data || {})), `${eventType} should use source descriptors instead of raw connector_id` @@ -897,7 +916,7 @@ test("event spine", async (t) => { assert.equal(deniedEvent.object_type, "pending_consent"); assert.equal(deniedEvent.status, "denied"); assert.equal(deniedEvent.data?.source?.kind, "connector"); - assert.equal(deniedEvent.data?.source?.id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(deniedEvent.data?.source?.id, spotifyManifest.connector_id); const grantIssuedEvent = (traceTimeline.data || []).find((event) => event.event_type === "grant.issued"); assert.equal(grantIssuedEvent, undefined, "denied consent should not issue a grant"); @@ -1094,11 +1113,7 @@ test("event spine", async (t) => { method: "POST", }); - const approveResp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: "u1" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); + const approveResp = await approveReviewedConsent(asUrl, initiate.request_uri, "u1"); assert.equal(approveResp.status, 200); const approval = (await approveResp.json()) as ConsentApprovalBody; @@ -1125,7 +1140,7 @@ test("event spine", async (t) => { assert.ok(queryReceived, "expected query.received for rejected connector grant read"); assert.equal(queryReceived.data.query_shape, "record_list"); assert.equal(queryReceived.data.source?.kind, "connector"); - assert.equal(queryReceived.data.source?.id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(queryReceived.data.source?.id, spotifyManifest.connector_id); assert.ok(!("connector_id" in (queryReceived.data || {}))); const rejected = (timeline.data || []).find( @@ -1134,7 +1149,7 @@ test("event spine", async (t) => { assert.ok(rejected, "expected query.rejected for rejected connector grant read"); assert.equal(rejected.data.query_shape, "record_list"); assert.equal(rejected.data.source?.kind, "connector"); - assert.equal(rejected.data.source?.id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(rejected.data.source?.id, spotifyManifest.connector_id); assert.equal(rejected.data.error?.code, "invalid_request"); assert.match(rejected.data.error?.message || "", REGEXP_2); assert.ok(!("connector_id" in (rejected.data || {}))); @@ -1165,11 +1180,7 @@ test("event spine", async (t) => { method: "POST", }); - const approveResp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: "u1" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); + const approveResp = await approveReviewedConsent(asUrl, initiate.request_uri, "u1"); assert.equal(approveResp.status, 200); const approval = (await approveResp.json()) as ConsentApprovalBody; @@ -1196,7 +1207,7 @@ test("event spine", async (t) => { assert.ok(queryReceived, "expected query.received for rejected connector unknown-field read"); assert.equal(queryReceived.data.query_shape, "record_list"); assert.equal(queryReceived.data.source?.kind, "connector"); - assert.equal(queryReceived.data.source?.id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(queryReceived.data.source?.id, spotifyManifest.connector_id); assert.ok(!("connector_id" in (queryReceived.data || {}))); const rejected = (timeline.data || []).find( @@ -1205,7 +1216,7 @@ test("event spine", async (t) => { assert.ok(rejected, "expected query.rejected for rejected connector unknown-field read"); assert.equal(rejected.data.query_shape, "record_list"); assert.equal(rejected.data.source?.kind, "connector"); - assert.equal(rejected.data.source?.id, canonicalConnectorKey(spotifyManifest.connector_id)); + assert.equal(rejected.data.source?.id, spotifyManifest.connector_id); assert.equal(rejected.data.error?.code, "unknown_field"); assert.match(rejected.data.error?.message || "", REGEXP_3); assert.ok(!("connector_id" in (rejected.data || {}))); @@ -1237,11 +1248,7 @@ test("event spine", async (t) => { assert.equal(parResp.status, 201); const initiate = (await parResp.json()) as ParInitiateBody; - const consentResp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: "employee_1" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); + const consentResp = await approveReviewedConsent(asUrl, initiate.request_uri, "employee_1"); assert.equal(consentResp.status, 200); const approval = (await consentResp.json()) as ConsentApprovalBody; @@ -1354,11 +1361,7 @@ test("event spine", async (t) => { assert.equal(parResp.status, 201); const initiate = (await parResp.json()) as ParInitiateBody; - const consentResp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: "employee_1" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); + const consentResp = await approveReviewedConsent(asUrl, initiate.request_uri, "employee_1"); assert.equal(consentResp.status, 200); const approval = (await consentResp.json()) as ConsentApprovalBody; @@ -1640,6 +1643,7 @@ test("event spine", async (t) => { await t.test("captures grant-scoped state artifacts on grant timelines", async () => { await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { const ownerToken = await issueOwnerToken(asUrl, "u1"); + await seedSpotify(rsUrl, spotifyManifest, ownerToken, { ownerSubjectId: "u1" }); const parResp = await fetch(`${asUrl}/oauth/par`, { body: JSON.stringify({ authorization_details: [ @@ -1660,11 +1664,7 @@ test("event spine", async (t) => { assert.equal(parResp.status, 201); const initiate = (await parResp.json()) as ParInitiateBody; - const consentResp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: "u1" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); + const consentResp = await approveReviewedConsent(asUrl, initiate.request_uri, "u1"); assert.equal(consentResp.status, 200); const approval = (await consentResp.json()) as ConsentApprovalBody; @@ -1928,6 +1928,16 @@ rl.on('line', (line) => { ], version: "0.1.0", }; + Object.assign(manifest, { + source_declaration: { + declaration_version: "event-spine-multi-stream-checkpoint-test.v1", + display: { name: "Event Spine Multi-Stream Checkpoint Test" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation/tests" }, + source: { id: "https://registry.pdpp.dev/connectors/event-spine-multi-stream-checkpoint-test", kind: "connector" }, + streams: manifest.streams, + }, + }); const tmpDir = mkdtempSync(join(tmpdir(), "pdpp-event-spine-multi-stream-commit-")); const connectorPath = join(tmpDir, "connector.mjs"); @@ -2024,6 +2034,7 @@ rl.on('line', (line) => { const asUrl = `http://localhost:${server.asPort}`; const manifest = { connector_id: "https://registry.pdpp.dev/connectors/event-spine-partial-checkpoint-failure-test", + protocol_version: "0.1.0", streams: [ { name: "items", @@ -2052,6 +2063,19 @@ rl.on('line', (line) => { ], version: "0.1.0", }; + Object.assign(manifest, { + source_declaration: { + declaration_version: "event-spine-partial-checkpoint-failure-test.v1", + display: { name: "Event Spine Partial Checkpoint Failure Test" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation/tests" }, + source: { + id: manifest.connector_id, + kind: "connector", + }, + streams: manifest.streams, + }, + }); const tmpDir = mkdtempSync(join(tmpdir(), "pdpp-event-spine-partial-checkpoint-failure-")); const connectorPath = join(tmpDir, "connector.mjs"); diff --git a/reference-implementation/test/example-client.test.ts b/reference-implementation/test/example-client.test.ts index bdfaaee1d..ccacb76a4 100644 --- a/reference-implementation/test/example-client.test.ts +++ b/reference-implementation/test/example-client.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; @@ -10,18 +12,24 @@ import { approveInline, buildParRequest, denyInline, - introspectToken, queryStreamRecords, queryStreams, registerClient, stageParRequest, } from "../examples/third-party-app/lib/flow.ts"; -import { buildDefaultDraft as buildDefaultDraftUntyped } from "../examples/third-party-app/server.ts"; +import { + buildDefaultDraft as buildDefaultDraftUntyped, + app as exampleApp, +} from "../examples/third-party-app/server.ts"; import { runConnector } from "../runtime/index.ts"; import { startServer as startServerUntyped } from "../server/index.ts"; import { DEFAULT_LOCAL_DCR_INITIAL_ACCESS_TOKEN } from "../server/reference-local-defaults.ts"; import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; import { admitOwnerRunConnection } from "../server/stores/connector-instance-store.ts"; +import { + TEST_INTROSPECTION_SERVER_OPTS, + type TEST_RS_INTROSPECTION_CREDENTIALS, +} from "./helpers/introspection-test-credentials.ts"; /** * Admission fixture for `runConnector`'s required `admitRunConnection` @@ -75,12 +83,15 @@ interface ClosableServer { interface StartServerOptions { asPort?: number; dbPath?: string; + introspectionCallerCredentials?: typeof TEST_RS_INTROSPECTION_CREDENTIALS; ownerAuthPassword?: string; ownerAuthSubjectId?: string; quiet?: boolean; + rsIntrospectionCredentials?: typeof TEST_RS_INTROSPECTION_CREDENTIALS; rsPort?: number; } const startServer = startServerUntyped as unknown as (opts: StartServerOptions) => Promise<ClosableServer>; +const appListen = exampleApp.listen.bind(exampleApp) as (port: number, callback: () => void) => Server; interface ConnectorManifest { connector_id: string; @@ -125,6 +136,18 @@ async function closeServer(server: ClosableServer): Promise<void> { await Promise.allSettled([closeOne(server.asServer), closeOne(server.rsServer)]); } +async function withExampleApp<T>(fn: (baseUrl: string) => Promise<T>): Promise<T> { + const server = await new Promise<Server>((resolve) => { + const listening = appListen(0, () => resolve(listening)); + }); + try { + const address = server.address() as AddressInfo; + return await fn(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise<void>((resolve) => server.close(() => resolve())); + } +} + async function registerSpotify(asUrl: string): Promise<ConnectorManifest> { const spotifyManifest = JSON.parse( readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8") @@ -212,7 +235,13 @@ async function seedSpotify({ } test("example client completes the current reference flow on the inline-approval path", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startServer({ + asPort: 0, + dbPath: ":memory:", + quiet: true, + rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, + }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -251,9 +280,6 @@ test("example client completes the current reference flow on the inline-approval assert.ok(approval.token.length > 0); assert.equal(typeof approval.grantId, "string"); - const introspection = await introspectToken({ asUrl, token: approval.token }); - assert.equal(introspection.active, true); - const streams = await queryStreams({ rsUrl, token: approval.token }); assert.ok(streams); assert.ok(Array.isArray(streams.streams) || typeof streams === "object"); @@ -262,8 +288,25 @@ test("example client completes the current reference flow on the inline-approval } }); +test("example third-party app does not expose public token introspection", async () => { + await withExampleApp(async (baseUrl) => { + const response = await fetch(`${baseUrl}/introspect`, { + body: new URLSearchParams({ token: "tok_example" }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(response.status, 404); + }); +}); + test("example client denies a staged request on the inline path", async () => { - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startServer({ + asPort: 0, + dbPath: ":memory:", + quiet: true, + rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, + }); const asUrl = `http://localhost:${server.asPort}`; try { @@ -306,6 +349,7 @@ test("example client surfaces owner-auth enabled as an honest failure instead of ownerAuthSubjectId: "owner_local", quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; @@ -349,7 +393,13 @@ test("example client shipped defaults stage a PAR request and reach records agai // without editing it, after registering the reference Spotify manifest the // normal way. If the shipped connector id or stream name drifts out of // the manifest, this test fails loudly. - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startServer({ + asPort: 0, + dbPath: ":memory:", + quiet: true, + rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, + }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; diff --git a/reference-implementation/test/field-capabilities-oracle.test.ts b/reference-implementation/test/field-capabilities-oracle.test.ts index afb9eba97..1c94d4cb7 100644 --- a/reference-implementation/test/field-capabilities-oracle.test.ts +++ b/reference-implementation/test/field-capabilities-oracle.test.ts @@ -59,7 +59,7 @@ const MANIFEST_STREAM = { }, }; -test("buildFieldCapabilities projects a granted field with declared type/role and capability flags", () => { +test("buildFieldCapabilities omits typed filter capabilities for grant metadata", () => { const caps = buildFieldCapabilities(MANIFEST_STREAM, { fields: ["amount", "body"] }); // biome-ignore lint/style/useDestructuring: Indexed access expresses the protocol field position under test. const amount = caps.amount; @@ -67,8 +67,8 @@ test("buildFieldCapabilities projects a granted field with declared type/role an assert.equal(amount.type, "currency"); // from x_pdpp_type assert.equal(amount.role, "metric"); // from x_pdpp_role assert.equal(amount.granted, true); - assert.deepEqual(amount.exact_filter, { declared: true, usable: true }); - assert.deepEqual(amount.range_filter, { declared: true, operators: ["gte", "lte"], usable: true }); + assert.equal(amount.exact_filter, undefined); + assert.equal(amount.range_filter, undefined); assert.deepEqual(amount.aggregation.sum, { declared: true, usable: true }); assert.deepEqual(amount.aggregation.group_by, { declared: true, usable: true }); // Undeclared aggregations are declared:false/usable:false. @@ -81,10 +81,7 @@ test("buildFieldCapabilities marks an ungranted field with field_not_granted on const secret = caps.secret; // not in the grant assert.ok(secret, "secret field capabilities must be present"); assert.equal(secret.granted, false); - // exact_filter is declared (string field) but ungranted => not usable, with reason. - assert.equal(secret.exact_filter.declared, true); - assert.equal(secret.exact_filter.usable, false); - assert.equal(secret.exact_filter.reason, "field_not_granted"); + assert.equal(secret.exact_filter, undefined); }); test("buildFieldCapabilities reflects lexical/semantic search declarations per field", () => { @@ -105,3 +102,11 @@ test("buildFieldCapabilities: a null grant (owner/unfiltered) marks every field assert.equal(caps.secret.exact_filter.usable, true); assert.ok(!("reason" in caps.secret.exact_filter), "a granted flag carries no field_not_granted reason"); }); + +test("buildFieldCapabilities retains typed filter capabilities for owner metadata", () => { + const caps = buildFieldCapabilities(MANIFEST_STREAM, null); + const { amount } = caps; + assert.ok(amount, "amount field capabilities must be present"); + assert.deepEqual(amount.exact_filter, { declared: true, usable: true }); + assert.deepEqual(amount.range_filter, { declared: true, operators: ["gte", "lte"], usable: true }); +}); diff --git a/reference-implementation/test/fixtures/device-ingest-failstop-server.ts b/reference-implementation/test/fixtures/device-ingest-failstop-server.ts index f1a301ba2..f2e55f340 100644 --- a/reference-implementation/test/fixtures/device-ingest-failstop-server.ts +++ b/reference-implementation/test/fixtures/device-ingest-failstop-server.ts @@ -107,7 +107,7 @@ process.stdout.write(`${JSON.stringify({ asPort: server.asPort, mode, ready: tru async function shutdown() { // biome-ignore lint/suspicious/noUnnecessaryConditions: localized test assertion preserves its explicit contract. server.abortStartupBackfill?.("fixture shutdown"); - server.schedulerManager?.stop?.(); + server.schedulerManager?.stop(); // biome-ignore lint/suspicious/noUnnecessaryConditions: localized test assertion preserves its explicit contract. server.stopBrowserSurfaceLeaseSweep?.(); if ("closeAllConnections" in server.asServer && typeof server.asServer.closeAllConnections === "function") { diff --git a/reference-implementation/test/fixtures/forbid-legacy-collection-loader.mjs b/reference-implementation/test/fixtures/forbid-legacy-collection-loader.mjs new file mode 100644 index 000000000..f4f1c623a --- /dev/null +++ b/reference-implementation/test/fixtures/forbid-legacy-collection-loader.mjs @@ -0,0 +1,20 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +const serverModuleRoot = "/reference-implementation/server/"; +const allowedServerModules = [ + "/server/core-source-authorization.ts", + "/server/record-filters.ts", + "/server/source-declaration.ts", +]; + +export async function resolve(specifier, context, nextResolve) { + const resolved = await nextResolve(specifier, context); + if ( + resolved.url.includes(serverModuleRoot) && + !allowedServerModules.some((allowed) => resolved.url.includes(allowed)) + ) { + throw new Error(`Core-only runtime loaded a Collection module: ${resolved.url}`); + } + return resolved; +} diff --git a/reference-implementation/test/grant-fan-in-fail-closed-no-phantom.test.ts b/reference-implementation/test/grant-fan-in-fail-closed-no-phantom.test.ts index c4738c843..d9780f0e7 100644 --- a/reference-implementation/test/grant-fan-in-fail-closed-no-phantom.test.ts +++ b/reference-implementation/test/grant-fan-in-fail-closed-no-phantom.test.ts @@ -15,9 +15,8 @@ * without pinning a `connector_instance_id` would resolve to that phantom * binding and read across a connection the owner never created. * - * After the fix, a read persists nothing, so fan-in resolution for an - * unconnected connector fails closed — it returns no binding (and reads zero - * records) exactly as if the owner had never connected. + * After the fix, a read persists nothing. A resolved grant keeps its frozen + * instance handle without widening to a synthesized default connection. */ import assert from "node:assert/strict"; @@ -50,6 +49,8 @@ const listedManifest = { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -67,7 +68,7 @@ function withDb(fn: () => Promise<void>): () => Promise<void> { } test( - "a dashboard read of an unconnected listed connector persists no connection and grant fan-in fails closed", + "a dashboard read persists no phantom connection and resolved grant fan-in does not widen", withDb(async () => { await registerConnector(listedManifest); @@ -88,8 +89,7 @@ test( "the read persisted no connector_instances row (no phantom connection)" ); - // Grant fan-in for a grant that names the connector but does NOT pin a - // connector_instance_id must fail closed: no active binding, zero records. + // The owner has no current active binding for this connector. const active = await listActiveBindingsForGrant({ connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, @@ -97,13 +97,17 @@ test( assert.deepEqual(active, [], "no active binding exists for an unconnected connector"); const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: ["cin_unconnected"], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); - assert.deepEqual( - bindings, - [], - "fan-in resolution must NOT bind to a phantom default-account connection; it fails closed" + assert.equal(bindings.length, 1, "resolved grant retains its one frozen instance handle"); + assert.equal(bindings[0]?.connectorId, CONNECTOR_ID); + assert.equal(bindings[0]?.connectorInstanceId, "cin_unconnected"); + assert.equal( + store.listByOwner(OWNER_AUTH_DEFAULT_SUBJECT_ID).length, + 0, + "resolution does not synthesize or persist a replacement default connection" ); }) ); diff --git a/reference-implementation/test/grant-package-postgres-path.test.ts b/reference-implementation/test/grant-package-postgres-path.test.ts index e6ad30461..ea2e9081c 100644 --- a/reference-implementation/test/grant-package-postgres-path.test.ts +++ b/reference-implementation/test/grant-package-postgres-path.test.ts @@ -51,11 +51,14 @@ import { listGrantPackagesForOwner, revokeGrantPackage, } from "../server/auth.ts"; -import { canonicalConnectorKeyFromManifest } from "../server/connector-key.ts"; +import { canonicalConnectorKey, canonicalConnectorKeyFromManifest } from "../server/connector-key.ts"; import { closeDb } from "../server/db.ts"; import { encodeHostedMcpSelection } from "../server/hosted-mcp-selection.ts"; import { startServer } from "../server/index.ts"; -import { closePostgresStorage } from "../server/postgres-storage.ts"; +import { basicIntrospectionAuthorization } from "../server/introspection-http.ts"; +import { closePostgresStorage, postgresQuery } from "../server/postgres-storage.ts"; +import { createPostgresConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; +import { TEST_RS_INTROSPECTION_CREDENTIALS } from "./helpers/introspection-test-credentials.ts"; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; @@ -262,6 +265,27 @@ interface Manifest { [key: string]: unknown; } +function packageInstanceId(connectorId: string): string { + return `cin_pkg_pg_${connectorId}`; +} + +async function seedPackageInstance(connectorId: string): Promise<void> { + const now = new Date().toISOString(); + const connectorInstanceId = packageInstanceId(connectorId); + await createPostgresConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId, + createdAt: now, + displayName: `${connectorId} package fixture`, + ownerSubjectId: "owner_local", + sourceBinding: { fixture: connectorInstanceId }, + sourceBindingKey: connectorInstanceId, + sourceKind: "manual", + status: "active", + updatedAt: now, + }); +} + async function registerConnector(asUrl: string, name: string): Promise<Manifest> { const raw = JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, `manifests/${name}.json`), "utf8")) as Manifest; const canonical = canonicalConnectorKeyFromManifest(raw); @@ -305,7 +329,7 @@ async function completeMultiSourcePackageFlow({ asUrl: string; client: AuthCodeClient; connectorIds: string[]; -}): Promise<{ packageId: string }> { +}): Promise<{ accessToken: string; expiresIn: number | undefined; packageId: string; refreshToken: string }> { const verifier = randomBytes(32).toString("base64url"); const state = "pkg-pg-test-state"; const challenge = pkceChallenge(verifier); @@ -330,7 +354,7 @@ async function completeMultiSourcePackageFlow({ params.append("code_challenge", challenge); params.append("code_challenge_method", "S256"); for (const id of connectorIds) { - params.append("selection", encodeHostedMcpSelection({ connectionId: null, connectorId: id })); + params.append("selection", encodeHostedMcpSelection({ connectionId: packageInstanceId(id), connectorId: id })); } for (const streamValue of renderedHostedMcpStreamValues(pickerHtml)) { params.append("stream", streamValue); @@ -342,7 +366,8 @@ async function completeMultiSourcePackageFlow({ method: "POST", redirect: "manual", }); - assert.equal(approveResp.status, 302); + const approveBody = await approveResp.text(); + assert.equal(approveResp.status, 302, approveBody); const location = approveResp.headers.get("location"); assert.ok(location, "the picker-approval redirect carries a location header"); const callback = new URL(location); @@ -361,9 +386,21 @@ async function completeMultiSourcePackageFlow({ method: "POST", }); assert.equal(status, 200); - const body = rawBody as { grant_package_id?: string }; + const body = rawBody as { + access_token?: string; + expires_in?: number; + grant_package_id?: string; + refresh_token?: string; + }; assert.ok(body.grant_package_id); - return { packageId: body.grant_package_id }; + assert.ok(body.access_token); + assert.ok(body.refresh_token); + return { + accessToken: body.access_token, + expiresIn: body.expires_in, + packageId: body.grant_package_id, + refreshToken: body.refresh_token, + }; } if (POSTGRES_URL) { @@ -384,6 +421,7 @@ if (POSTGRES_URL) { asPort: 0, databaseUrl: POSTGRES_URL, dbPath: ":memory:", + introspectionCallerCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, ownerAuthPassword: "", quiet: true, reconcilePolyfillManifests: false, @@ -394,6 +432,8 @@ if (POSTGRES_URL) { asUrl = `http://localhost:${server.asPort}`; spotify = await registerConnector(asUrl, "spotify"); github = await registerConnector(asUrl, "github"); + await seedPackageInstance(spotify.connector_id); + await seedPackageInstance(github.connector_id); client = await registerAuthCodeClient(asUrl); }); @@ -465,8 +505,68 @@ if (POSTGRES_URL) { assert.equal(typeof member.grant_id, "string"); assert.equal(typeof member.token, "string", "member exposes its child grant token"); assert.ok(member.grant, "member carries the parsed child grant"); + const grant = requireObject(member.grant, "member grant must be an object"); + const source = requireObject(grant.source, "member source must be an object"); + const sourceId = requireString(source.id, "member source id must be a string"); + const storageConnectorId = canonicalConnectorKey(sourceId); + assert.ok(storageConnectorId, "member public source id maps to its local fulfillment key"); + for (const streamValue of requireArray(grant.streams, "member grant streams must be an array")) { + const stream = requireObject(streamValue, "member grant stream must be an object"); + assert.deepEqual(stream.instance_ids, [packageInstanceId(storageConnectorId)]); + } } + const memberIdentityRows = await postgresQuery<{ + grant_id: string; + grant_json: Record<string, unknown>; + token_id: string; + }>( + `SELECT gm.grant_id, gm.token_id, g.grant_json + FROM grant_package_members gm + JOIN grants g ON g.grant_id = gm.grant_id + WHERE gm.package_id = $1 + ORDER BY gm.grant_id + LIMIT 1`, + [packageId] + ); + const [memberIdentity] = memberIdentityRows.rows; + assert.ok(memberIdentity); + const originalGrant = structuredClone(memberIdentity.grant_json); + const foreignGrant = structuredClone(originalGrant); + foreignGrant.subject = { id: "owner_foreign_package_member" }; + await postgresQuery("UPDATE grants SET subject_id = $1, grant_json = $2::jsonb WHERE grant_id = $3", [ + "owner_foreign_package_member", + JSON.stringify(foreignGrant), + memberIdentity.grant_id, + ]); + await postgresQuery("UPDATE tokens SET subject_id = $1 WHERE token_id = $2", [ + "owner_foreign_package_member", + memberIdentity.token_id, + ]); + const foreignAccessResponse = await getGrantPackageAccess(packageId); + assert.ok(foreignAccessResponse); + assert.equal( + grantPackageAccess(foreignAccessResponse).members.length, + 1, + "the package omits a valid child whose subject no longer matches its parent" + ); + const packageSubject = requireString( + requireObject(requireObject(accessResponse, "package access").package, "package envelope").subject_id, + "package subject_id must be a string" + ); + await postgresQuery("UPDATE grants SET subject_id = $1, grant_json = $2::jsonb WHERE grant_id = $3", [ + packageSubject, + JSON.stringify(originalGrant), + memberIdentity.grant_id, + ]); + await postgresQuery("UPDATE tokens SET subject_id = $1 WHERE token_id = $2", [ + packageSubject, + memberIdentity.token_id, + ]); + const restoredAccess = await getGrantPackageAccess(packageId); + assert.ok(restoredAccess); + assert.equal(grantPackageAccess(restoredAccess).members.length, 2); + // getGrantPackageIdForGrant: member-by-grant SELECT. Every child grant // resolves back to this package; the package token (NULL grant_id) does // not participate. @@ -477,6 +577,78 @@ if (POSTGRES_URL) { } }); + test("package refresh replay deactivates every family-linked bearer through real postgres adapters", async () => { + assert.ok(client && spotify && github, "premise: test.before registered the client and connectors"); + const packageClient = client; + const issued = await completeMultiSourcePackageFlow({ + asUrl, + client, + connectorIds: [spotify.connector_id, github.connector_id], + }); + assert.ok(issued.expiresIn && issued.expiresIn <= 600, "family-linked package bearer has a short lifetime"); + + const rotate = async (refreshToken: string) => + fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: packageClient.client_id, + grant_type: "refresh_token", + refresh_token: refreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + const attacker = await rotate(issued.refreshToken); + assert.equal(attacker.status, 200, JSON.stringify(attacker.body)); + const attackerBody = requireObject(attacker.body, "attacker refresh response must be an object"); + assert.ok( + requireNumber(attackerBody.expires_in, "attacker bearer expires_in must be a number") <= 600, + "refresh-derived package bearer has a short lifetime" + ); + assert.ok(typeof attackerBody.access_token === "string"); + assert.ok(typeof attackerBody.refresh_token === "string"); + + const replay = await rotate(issued.refreshToken); + assert.equal(replay.status, 400); + const replayBody = requireObject(replay.body, "replay response must be an object"); + assert.equal(replayBody.error, "invalid_grant"); + assert.equal(replayBody.fresh_authorization_required, true); + + const family = await postgresQuery<{ family_id: string }>( + `SELECT family_id + FROM oauth_refresh_tokens + WHERE refresh_token_hash = $1`, + [createHash("sha256").update(issued.refreshToken).digest("base64url")] + ); + const familyId = requireString(family.rows[0]?.family_id, "refresh family id must be persisted"); + const bearers = await postgresQuery<{ expires_at: string | null; revoked: boolean; token_id: string }>( + `SELECT token_id, expires_at, revoked + FROM tokens + WHERE refresh_family_id = $1 + ORDER BY created_at, token_id`, + [familyId] + ); + assert.equal(bearers.rows.length, 2, "the initial and attacker-minted package bearers share the family"); + const introspectionHeaders = { + Authorization: basicIntrospectionAuthorization(TEST_RS_INTROSPECTION_CREDENTIALS), + "Content-Type": "application/x-www-form-urlencoded", + }; + for (const bearer of bearers.rows) { + assert.equal(bearer.revoked, true, "replay revokes every family-linked package bearer row"); + assert.ok(bearer.expires_at, "every family-linked package bearer has an expiry"); + // biome-ignore lint/performance/noAwaitInLoops: Each persisted family bearer is an independent security assertion. + const introspection = await fetchJson(`${asUrl}/introspect`, { + body: new URLSearchParams({ token: bearer.token_id }).toString(), + headers: introspectionHeaders, + method: "POST", + }); + assert.equal(introspection.status, 200); + assert.equal(requireObject(introspection.body, "introspection body must be an object").active, false); + } + + const successor = await rotate(requireString(attackerBody.refresh_token, "attacker successor must be a string")); + assert.equal(successor.status, 400, "family replay revokes the attacker successor"); + }); + // --------------------------------------------------------------------- // B) Revoke cascade through the real Postgres adapters. // diff --git a/reference-implementation/test/helpers/aggregation-rows-conformance.ts b/reference-implementation/test/helpers/aggregation-rows-conformance.ts index acc79d407..49371b683 100644 --- a/reference-implementation/test/helpers/aggregation-rows-conformance.ts +++ b/reference-implementation/test/helpers/aggregation-rows-conformance.ts @@ -83,6 +83,7 @@ export const CONFORMANCE_STREAM_B = "accounts"; export const CONFORMANCE_MANIFEST = { connector_id: CONFORMANCE_CONNECTOR_ID, display_name: "Aggregation Rows Conformance", + manifest_uri: `https://sources.example/${CONFORMANCE_CONNECTOR_ID}`, protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: false } } }, streams: [ @@ -98,6 +99,7 @@ export const CONFORMANCE_MANIFEST = { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "mutable_state", }, { @@ -111,6 +113,7 @@ export const CONFORMANCE_MANIFEST = { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "mutable_state", }, ], diff --git a/reference-implementation/test/helpers/broken-consent-device-auth-driver.ts b/reference-implementation/test/helpers/broken-consent-device-auth-driver.ts index a7fbda787..34038ea7f 100644 --- a/reference-implementation/test/helpers/broken-consent-device-auth-driver.ts +++ b/reference-implementation/test/helpers/broken-consent-device-auth-driver.ts @@ -10,12 +10,11 @@ * The breaks here are not random. Each one mimics a plausible storage-driver * mistake that would compromise the reference's security/lifecycle posture: * - * 1. Pending consent re-approval is allowed. A driver that does + * 1. Pending consent re-approval mints a second result. A driver that does * `mark_approved` without checking the prior status (or that uses an * idempotent UPSERT keyed on device_code) lets the same consent get - * re-approved twice. The harness's "approval is terminal" scenario - * pins this — under this broken driver it MUST fail because the - * second approval call succeeds and re-mints a grant. + * re-approved twice. The harness's durable-resume scenario pins this: + * the second call may succeed, but it MUST return the persisted result. * * 2. Owner-device denial does not transition the row to a terminal * `denied` status; it simply removes the public-lookup entry. The diff --git a/reference-implementation/test/helpers/consent-device-auth-conformance.ts b/reference-implementation/test/helpers/consent-device-auth-conformance.ts index 30a14698f..b97e91054 100644 --- a/reference-implementation/test/helpers/consent-device-auth-conformance.ts +++ b/reference-implementation/test/helpers/consent-device-auth-conformance.ts @@ -196,12 +196,10 @@ export function runConsentDeviceAuthConformance({ } }); - // 2. Approval terminates the pending row. After approval, the public - // lookup MUST stop returning the pending view (the row is no longer - // available for re-approval), and a second approval MUST fail with - // `not_found`. This pins the terminal-state invariant: approve is a - // one-shot transition, not idempotent. - t("pending consent: approval is terminal — public lookup disappears and re-approval fails", async () => { + // 2. Approval terminates the pending row. After approval, the public lookup + // MUST stop returning the pending view. A retry MAY resume delivery, but + // it MUST return the exact persisted issuance and MUST NOT mint again. + t("pending consent: approval is terminal — public lookup disappears and retry resumes exact issuance", async () => { const driver = await makeDriver(); await driver.setup(); try { @@ -220,19 +218,8 @@ export function runConsentDeviceAuthConformance({ const afterApprove = await driver.lookupPendingConsentByRequestUri(start.request_uri); assert.equal(afterApprove, null, "after approval, public lookup MUST NOT return a pending view"); - // biome-ignore lint/suspicious/noEvolvingTypes: localized test assertion preserves its explicit contract. - let reApproveError = null; - try { - await driver.approvePendingConsent(start.request_uri); - } catch (err) { - reApproveError = err; - } - assert.ok(reApproveError, "re-approval after approval MUST throw"); - assert.equal( - errorCode(reApproveError), - "not_found", - `re-approval error MUST carry code='not_found'; got '${errorCode(reApproveError)}'` - ); + const resumed = await driver.approvePendingConsent(start.request_uri); + assert.deepEqual(resumed, result, "approval retry MUST resume the exact persisted grant and token"); } finally { await driver.teardown(); } @@ -559,64 +546,49 @@ export function runConsentDeviceAuthConformance({ } }); - // 9b. Owner-device approval is terminal. After approveOwnerDeviceAuth - // succeeds, a second approveOwnerDeviceAuth on the same user_code - // MUST fail with `not_found` — re-approval cannot re-mint a second - // owner token against the same row. The originally-issued token - // remains usable for exchange (the row is bound to it), so the - // poller's contract is not retroactively broken by the rejected - // re-approval. This pairs with scenario 2's pending-consent - // terminal-approval invariant; the same invariant must hold for - // the owner-device flow. - t( - "owner device auth: approval is terminal — re-approval throws not_found, original token still exchanges", - async () => { - const driver = await makeDriver(); - await driver.setup(); - try { - const start = await driver.startOwnerDeviceAuth({}); - - const firstApprove = await driver.approveOwnerDeviceAuth(start.user_code); - assert.ok(firstApprove.access_token, "first approveOwnerDeviceAuth MUST mint an access_token"); - const originalToken = firstApprove.access_token; - - // biome-ignore lint/suspicious/noEvolvingTypes: localized test assertion preserves its explicit contract. - let reApproveErr = null; - try { - await driver.approveOwnerDeviceAuth(start.user_code); - } catch (err) { - reApproveErr = err; - } - assert.ok(reApproveErr, "re-approval after approval MUST throw"); - assert.equal( - errorCode(reApproveErr), - "not_found", - `re-approval error MUST carry code='not_found'; got '${errorCode(reApproveErr)}'` - ); - - // The original token MUST still exchange — the rejected re-approval - // is not allowed to invalidate the already-issued bearer. - const exchange = await driver.exchangeOwnerDeviceCode({ - client_id: driver.getRegisteredClientId(), - device_code: start.device_code, - }); - assert.ok( - exchange.access_token, - "exchange after a rejected re-approval MUST still return the original access_token" - ); - assert.equal( - exchange.access_token, - originalToken, - "exchange MUST return the token minted by the FIRST approval, unchanged" - ); - } finally { - await driver.teardown(); - } + // 9b. Owner-device approval is terminal and retry-idempotent. After + // approveOwnerDeviceAuth succeeds, a second approveOwnerDeviceAuth on + // the same user_code MUST return the originally-bound owner token, not + // re-mint a second bearer. This is the response-loss retry contract: + // the poller's exchange result and the approval retry result agree. + t("owner device auth: approval is terminal — re-approval returns the original token", async () => { + const driver = await makeDriver(); + await driver.setup(); + try { + const start = await driver.startOwnerDeviceAuth({}); + + const firstApprove = await driver.approveOwnerDeviceAuth(start.user_code); + assert.ok(firstApprove.access_token, "first approveOwnerDeviceAuth MUST mint an access_token"); + const originalToken = firstApprove.access_token; + + const reApprove = await driver.approveOwnerDeviceAuth(start.user_code); + assert.equal( + reApprove.access_token, + originalToken, + "re-approval after approval MUST return the originally-bound access_token" + ); + + // The original token MUST still exchange after the approval retry. + const exchange = await driver.exchangeOwnerDeviceCode({ + client_id: driver.getRegisteredClientId(), + device_code: start.device_code, + }); + assert.ok( + exchange.access_token, + "exchange after a re-approval retry MUST still return the original access_token" + ); + assert.equal( + exchange.access_token, + originalToken, + "exchange MUST return the token minted by the FIRST approval, unchanged" + ); + } finally { + await driver.teardown(); } - ); + }); // 10. Denial terminates the row. After denial, lookup MUST return null, - // approval MUST throw `not_found`, and exchange MUST throw + // approval MUST throw `approval_conflict`, and exchange MUST throw // `access_denied`. This pins the denied-vs-approved distinction // surfaced to the polling client. t("owner device auth: denial is terminal — exchange throws access_denied", async () => { @@ -640,8 +612,8 @@ export function runConsentDeviceAuthConformance({ assert.ok(approveErr, "approve after deny MUST throw"); assert.equal( errorCode(approveErr), - "not_found", - `approve-after-deny MUST throw code='not_found'; got '${errorCode(approveErr)}'` + "approval_conflict", + `approve-after-deny MUST throw code='approval_conflict'; got '${errorCode(approveErr)}'` ); // biome-ignore lint/suspicious/noEvolvingTypes: localized test assertion preserves its explicit contract. diff --git a/reference-implementation/test/helpers/introspection-test-credentials.ts b/reference-implementation/test/helpers/introspection-test-credentials.ts new file mode 100644 index 000000000..7a58f6fc7 --- /dev/null +++ b/reference-implementation/test/helpers/introspection-test-credentials.ts @@ -0,0 +1,14 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { IntrospectionCallerCredentials } from "../../server/introspection-http.ts"; + +export const TEST_RS_INTROSPECTION_CREDENTIALS: IntrospectionCallerCredentials = { + clientId: "pr89-rs-test", + clientSecret: "pr89-rs-test-secret", +}; + +export const TEST_INTROSPECTION_SERVER_OPTS = { + introspectionCallerCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + rsIntrospectionCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, +} as const; diff --git a/reference-implementation/test/helpers/introspection.ts b/reference-implementation/test/helpers/introspection.ts new file mode 100644 index 000000000..1f995bdef --- /dev/null +++ b/reference-implementation/test/helpers/introspection.ts @@ -0,0 +1,11 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { basicIntrospectionAuthorization } from "../../server/introspection-http.ts"; +import { TEST_RS_INTROSPECTION_CREDENTIALS } from "./introspection-test-credentials.ts"; + +const INTROSPECTION_AUTHORIZATION = basicIntrospectionAuthorization(TEST_RS_INTROSPECTION_CREDENTIALS); + +export function introspectionHeaders(contentType = "application/json"): Record<string, string> { + return { Authorization: INTROSPECTION_AUTHORIZATION, "Content-Type": contentType }; +} diff --git a/reference-implementation/test/helpers/memory-consent-device-auth-driver.ts b/reference-implementation/test/helpers/memory-consent-device-auth-driver.ts index fe87de82f..49a6a308d 100644 --- a/reference-implementation/test/helpers/memory-consent-device-auth-driver.ts +++ b/reference-implementation/test/helpers/memory-consent-device-auth-driver.ts @@ -4,8 +4,8 @@ /** * Conforming in-memory driver for the consent + owner-device-auth conformance harness. * - * Test-only second adapter that mirrors the SQLite reference's terminal-state - * semantics, approval-id indirection, expiry behavior, owner-device polling + * Test-only second adapter that mirrors the SQLite reference's durable + * approval-resume semantics, approval-id indirection, expiry behavior, owner-device polling * `slow_down` enforcement, denial-vs-approval terminal distinction, and * polling exchange shape — without touching SQLite, the file system, or the * production auth helpers. Its purpose is the storage-only security proof @@ -175,12 +175,17 @@ export function createMemoryConsentDeviceAuthDriver() { err.code = "not_found"; throw err; } + if (row.status === "approved" && row.token_id) { + return { + access_token: row.token_id, + expires_in: 365 * 24 * 60 * 60, + subject_id: row.subject_id || "owner_local", + token_type: "Bearer", + }; + } if (row.status !== "pending") { - // Terminal state — re-approval is rejected and the originally- - // issued token (if any) stays bound to the row. Pins scenario 9b's - // "approval is terminal" invariant. const err = codedError("Owner device authorization is not available"); - err.code = "not_found"; + err.code = "approval_conflict"; throw err; } if (isPast(row.expires_at)) { @@ -209,9 +214,13 @@ export function createMemoryConsentDeviceAuthDriver() { err.code = "not_found"; throw err; } + if (row.status === "approved" && row.grant_id && row.token_id) { + return { + grant: { grant_id: row.grant_id, version: "0.1.0" }, + token: row.token_id, + }; + } if (row.status !== "pending") { - // Terminal state (approved, denied, expired) — re-approval is not - // allowed. This pins scenario 2's "approval is terminal" invariant. const err = codedError("Pending consent request is not available"); err.code = "not_found"; throw err; @@ -253,7 +262,7 @@ export function createMemoryConsentDeviceAuthDriver() { err.code = "not_found"; throw err; } - // Mirror SQLite's `markOwnerDeviceAuthDenied`: flip status to `denied` + // Mirror SQLite's `markDeniedAtomically`: flip status to `denied` // so polling exchange returns `access_denied`, not // `authorization_pending`. This is the invariant break-2 in the broken // driver — keep it correct here. diff --git a/reference-implementation/test/helpers/postgres-consent-device-auth-driver.ts b/reference-implementation/test/helpers/postgres-consent-device-auth-driver.ts index 1c2feadb3..3b2582149 100644 --- a/reference-implementation/test/helpers/postgres-consent-device-auth-driver.ts +++ b/reference-implementation/test/helpers/postgres-consent-device-auth-driver.ts @@ -170,6 +170,14 @@ export function createPostgresConsentDeviceAuthDriver({ connectionString }: { co if (!row) { throw codedError("Unknown user code", "not_found"); } + if (row.status === "approved" && typeof row.token_id === "string") { + return { + access_token: row.token_id, + expires_in: 365 * 24 * 60 * 60, + subject_id: row.subject_id || "owner_local", + token_type: "Bearer", + }; + } if (row.status !== "pending") { throw codedError("Owner device authorization is not available", "not_found"); } diff --git a/reference-implementation/test/helpers/postgres-temp-database.js b/reference-implementation/test/helpers/postgres-temp-database.js index 15da3d5dd..87863ea93 100644 --- a/reference-implementation/test/helpers/postgres-temp-database.js +++ b/reference-implementation/test/helpers/postgres-temp-database.js @@ -1,6 +1,4 @@ -import pg from "pg"; - -const { Pool } = pg; +import { Pool } from "pg"; function adminUrl(connectionString) { const url = new URL(connectionString); diff --git a/reference-implementation/test/helpers/production-consent-device-auth-driver.ts b/reference-implementation/test/helpers/production-consent-device-auth-driver.ts index f0a17239e..3dd4f30a3 100644 --- a/reference-implementation/test/helpers/production-consent-device-auth-driver.ts +++ b/reference-implementation/test/helpers/production-consent-device-auth-driver.ts @@ -148,7 +148,21 @@ export function createProductionConsentDeviceAuthDriver() { if (!deviceCode) { throw new Error("pending consent request URI is invalid"); } - return normalizeApprovalResult(await consentStore?.approveGrant(deviceCode)); + const review = await consentStore?.getPendingConsentByDeviceCode(deviceCode, { + finalizeReview: true, + subjectId: "owner_local", + }); + if (review === null) { + return normalizeApprovalResult(await consentStore?.approveGrant(deviceCode, "owner_local")); + } + if (typeof review?.reviewRevision !== "string") { + throw new Error("pending consent review revision was not materialized"); + } + return normalizeApprovalResult( + await consentStore?.approveGrant(deviceCode, "owner_local", { + approval_review_revision: review.reviewRevision, + }) + ); }, async denyOwnerDeviceAuth(userCode: string) { diff --git a/reference-implementation/test/helpers/production-store-connector-state-scheduler-driver.ts b/reference-implementation/test/helpers/production-store-connector-state-scheduler-driver.ts index e9f1e8e7e..b2b633d7d 100644 --- a/reference-implementation/test/helpers/production-store-connector-state-scheduler-driver.ts +++ b/reference-implementation/test/helpers/production-store-connector-state-scheduler-driver.ts @@ -78,6 +78,7 @@ const HARNESS_MANIFESTS = [ { connector_id: CONNECTOR_A, display_name: "Conformance Connector A", + manifest_uri: `https://sources.example/${CONNECTOR_A}`, protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ @@ -85,12 +86,14 @@ const HARNESS_MANIFESTS = [ name: "stream_x", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "mutable_state", }, { name: "stream_y", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "mutable_state", }, ], @@ -99,6 +102,7 @@ const HARNESS_MANIFESTS = [ { connector_id: CONNECTOR_B, display_name: "Conformance Connector B", + manifest_uri: `https://sources.example/${CONNECTOR_B}`, protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ @@ -106,6 +110,7 @@ const HARNESS_MANIFESTS = [ name: "stream_x", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "mutable_state", }, ], diff --git a/reference-implementation/test/helpers/record-read-conformance.ts b/reference-implementation/test/helpers/record-read-conformance.ts index 00ac8d601..8d347258e 100644 --- a/reference-implementation/test/helpers/record-read-conformance.ts +++ b/reference-implementation/test/helpers/record-read-conformance.ts @@ -134,6 +134,9 @@ export const CONFORMANCE_NULLABLE_CURSOR_STREAM = "budgets"; export const CONFORMANCE_MANIFEST = { connector_id: CONFORMANCE_CONNECTOR_ID, display_name: "Record Read Conformance", + // `connector_id` is the private storage key. SourceDeclaration requires + // the public authorization identity to be an absolute URI. + manifest_uri: `https://sources.example/${CONFORMANCE_CONNECTOR_ID}`, protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ diff --git a/reference-implementation/test/helpers/sqlite-connector-state-scheduler-driver.ts b/reference-implementation/test/helpers/sqlite-connector-state-scheduler-driver.ts index 49d1f1077..a052200fa 100644 --- a/reference-implementation/test/helpers/sqlite-connector-state-scheduler-driver.ts +++ b/reference-implementation/test/helpers/sqlite-connector-state-scheduler-driver.ts @@ -73,6 +73,7 @@ const HARNESS_MANIFESTS = [ { connector_id: CONNECTOR_A, display_name: "Conformance Connector A", + manifest_uri: `https://sources.example/${CONNECTOR_A}`, protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ @@ -80,12 +81,14 @@ const HARNESS_MANIFESTS = [ name: "stream_x", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "mutable_state", }, { name: "stream_y", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "mutable_state", }, ], @@ -94,6 +97,7 @@ const HARNESS_MANIFESTS = [ { connector_id: CONNECTOR_B, display_name: "Conformance Connector B", + manifest_uri: `https://sources.example/${CONNECTOR_B}`, protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ @@ -101,6 +105,7 @@ const HARNESS_MANIFESTS = [ name: "stream_x", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "mutable_state", }, ], diff --git a/reference-implementation/test/helpers/sqlite-consent-device-auth-driver.ts b/reference-implementation/test/helpers/sqlite-consent-device-auth-driver.ts index 32b57b310..525445746 100644 --- a/reference-implementation/test/helpers/sqlite-consent-device-auth-driver.ts +++ b/reference-implementation/test/helpers/sqlite-consent-device-auth-driver.ts @@ -117,10 +117,16 @@ export function createSqliteConsentDeviceAuthDriver() { return approveOwnerDeviceAuthorization(userCode); }, - // biome-ignore lint/suspicious/useAwait: mock preserves the production Promise contract and rejection timing async approvePendingConsent(requestUri: string) { const deviceCode = pendingConsentDeviceCode(requestUri); - return approveGrant(deviceCode); + const review = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + if (review === null) { + return approveGrant(deviceCode, "owner_local"); + } + if (typeof review?.reviewRevision !== "string") { + throw new Error("pending consent review revision was not materialized"); + } + return approveGrant(deviceCode, "owner_local", { approval_review_revision: review.reviewRevision }); }, // biome-ignore lint/suspicious/useAwait: mock preserves the production Promise contract and rejection timing diff --git a/reference-implementation/test/hosted-mcp-oauth.test.ts b/reference-implementation/test/hosted-mcp-oauth.test.ts index e2f0c73f5..71c84983c 100644 --- a/reference-implementation/test/hosted-mcp-oauth.test.ts +++ b/reference-implementation/test/hosted-mcp-oauth.test.ts @@ -5,26 +5,35 @@ import assert from "node:assert/strict"; import { createHash, randomBytes } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import http from "node:http"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +// biome-ignore lint/correctness/noUnresolvedImports: Biome resolver cannot model this installed package export +import Database from "better-sqlite3"; import { buildPendingConsentRequestUri, getGrantPackageAccess, revokeGrant, revokeGrantPackage, } from "../server/auth.ts"; -import { canonicalConnectorKeyFromManifest } from "../server/connector-key.ts"; -import { getDb } from "../server/db.ts"; +import { canonicalConnectorKey, canonicalConnectorKeyFromManifest } from "../server/connector-key.ts"; +import { closeDb, getDb, initDb } from "../server/db.ts"; import { encodeHostedMcpSelection, encodeHostedMcpStreamSelection } from "../server/hosted-mcp-selection.ts"; import { startServer } from "../server/index.ts"; +import { basicIntrospectionAuthorization } from "../server/introspection-http.ts"; import { ingestRecord, queryRecordsAcrossBindings, resolveReadRequestBindings } from "../server/records.ts"; import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; +import { + TEST_INTROSPECTION_SERVER_OPTS, + TEST_RS_INTROSPECTION_CREDENTIALS, +} from "./helpers/introspection-test-credentials.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); +const INTROSPECTION_AUTHORIZATION = basicIntrospectionAuthorization(TEST_RS_INTROSPECTION_CREDENTIALS); interface CloseableTestServer { readonly asPort: number; @@ -54,6 +63,41 @@ async function fetchJson(url: string | URL, opts: RequestInit = {}): Promise<Jso return { body, resp, status: resp.status }; } +async function introspectAccessToken(asUrl: string, token: string): Promise<Record<string, unknown>> { + const response = await fetchJson(`${asUrl}/introspect`, { + body: new URLSearchParams({ token }).toString(), + headers: { + Authorization: INTROSPECTION_AUTHORIZATION, + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }); + assert.equal(response.status, 200); + return response.body; +} + +async function reviewConsent( + asUrl: string, + requestUri: string, + subjectId = "owner_local", + authorization?: string +): Promise<string> { + const response = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId }), + headers: { + Accept: "application/json", + "Content-Type": "application/json", + ...(authorization ? { Authorization: authorization } : {}), + }, + method: "POST", + }); + const body = (await response.json()) as { approval_review?: unknown; approval_review_revision?: unknown }; + assert.equal(response.status, 200, JSON.stringify(body)); + assert.ok(body.approval_review && typeof body.approval_review === "object"); + assert.equal(typeof body.approval_review_revision, "string"); + return body.approval_review_revision as string; +} + function mustExist<T>(value: T | null | undefined, description: string): T { assert.ok(value, description); return value; @@ -196,6 +240,24 @@ interface ConnectorManifest { [key: string]: unknown; } +function publicSourceIdForManifest(manifest: ConnectorManifest): string { + const declaration = manifest.source_declaration; + if (declaration && typeof declaration === "object") { + const { source } = declaration as Record<string, unknown>; + if (source && typeof source === "object") { + const sourceId = (source as Record<string, unknown>).id; + if (typeof sourceId === "string") { + return sourceId; + } + } + } + try { + return new URL(manifest.connector_id).href; + } catch { + return `https://registry.pdpp.dev/connectors/${encodeURIComponent(manifest.connector_id)}`; + } +} + // Register a first-party connector fixture with the AS using its canonical // short connector key (e.g. `spotify`, `github`). The fixture manifests on // disk still ship URL-shaped `connector_id` values for catalog purposes, but @@ -233,6 +295,40 @@ async function registerGithub(asUrl: string): Promise<ConnectorManifest> { return registerFirstPartyConnectorFixture(asUrl, "github"); } +function defaultHostedInstanceId(connectorId: string): string { + return `cin_hosted_${connectorId}`; +} + +async function seedDefaultHostedInstance(manifest: ConnectorManifest): Promise<string> { + const connectorInstanceId = defaultHostedInstanceId(manifest.connector_id); + const now = new Date().toISOString(); + await createSqliteConnectorInstanceStore().upsert({ + connectorId: manifest.connector_id, + connectorInstanceId, + createdAt: now, + displayName: `${manifest.connector_id} test account`, + ownerSubjectId: "owner_local", + sourceBinding: { fixture: connectorInstanceId }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); + return connectorInstanceId; +} + +async function registerAuthorizedSpotify(asUrl: string): Promise<ConnectorManifest> { + const manifest = await registerSpotify(asUrl); + await seedDefaultHostedInstance(manifest); + return manifest; +} + +async function registerAuthorizedGithub(asUrl: string): Promise<ConnectorManifest> { + const manifest = await registerGithub(asUrl); + await seedDefaultHostedInstance(manifest); + return manifest; +} + interface RegisteredClient { client_id: string; client_secret?: string; @@ -308,10 +404,9 @@ async function issueOwnerToken(asUrl: string): Promise<string> { const approveResp = await fetch(`${asUrl}/device/approve`, { body: new URLSearchParams({ - subject_id: "owner_local", user_code: stringField(device, "user_code"), }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", }); assert.equal(approveResp.status, 200); @@ -331,30 +426,23 @@ async function issueOwnerToken(asUrl: string): Promise<string> { interface OauthCodeFlowResult { accessToken: string; code: string; + expiresIn: number | null; grantId: string | undefined; refreshToken: string | null; } -async function completeOauthCodeFlow({ +async function prepareOauthCodeFlow({ asUrl, + accessMode = "continuous", client, manifest, }: { asUrl: string; + accessMode?: "continuous" | "single_use"; client: RegisteredClient; manifest: ConnectorManifest; -}): Promise<OauthCodeFlowResult> { +}): Promise<{ code: string; verifier: string }> { const verifier = randomBytes(32).toString("base64url"); - const authorizationDetails = [ - { - access_mode: "continuous", - purpose_code: "https://pdpp.dev/purpose/personal_ai_assistant", - purpose_description: "Use PDPP data through hosted MCP.", - source: { id: manifest.connector_id, kind: "connector" }, - streams: [{ name: "*" }], - type: "https://pdpp.dev/data-access", - }, - ]; const authorizeUrl = new URL(`${asUrl}/oauth/authorize`); authorizeUrl.searchParams.set("client_id", client.client_id); authorizeUrl.searchParams.set("redirect_uri", "https://client.example/callback"); @@ -362,7 +450,10 @@ async function completeOauthCodeFlow({ authorizeUrl.searchParams.set("state", "state-123"); authorizeUrl.searchParams.set("code_challenge", pkceChallenge(verifier)); authorizeUrl.searchParams.set("code_challenge_method", "S256"); - authorizeUrl.searchParams.set("authorization_details", JSON.stringify(authorizationDetails)); + authorizeUrl.searchParams.set( + "authorization_details", + JSON.stringify(hostedMcpAuthorizationDetails(manifest, accessMode)) + ); const authorizeResp = await fetch(authorizeUrl, { redirect: "manual" }); assert.equal(authorizeResp.status, 302); @@ -371,17 +462,14 @@ async function completeOauthCodeFlow({ asUrl ); const requestUri = mustExist(consentUrl.searchParams.get("request_uri"), "consent redirect must carry request_uri"); - + const reviewRevision = await reviewConsent(asUrl, requestUri); const approveResp = await fetch(`${asUrl}/consent/approve`, { - body: new URLSearchParams({ - request_uri: requestUri, - subject_id: "owner_local", - }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ approval_review_revision: reviewRevision, request_uri: requestUri }).toString(), + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", redirect: "manual", }); - assert.equal(approveResp.status, 302); + assert.equal(approveResp.status, 302, await approveResp.clone().text()); const callback = new URL( mustExist(approveResp.headers.get("location"), "approve redirect must carry a Location header") ); @@ -389,7 +477,24 @@ async function completeOauthCodeFlow({ assert.equal(callback.searchParams.get("state"), "state-123"); assert.equal(callback.searchParams.has("access_token"), false); assert.equal(callback.searchParams.has("grant"), false); - const code = mustExist(callback.searchParams.get("code"), "callback must carry an authorization code"); + return { + code: mustExist(callback.searchParams.get("code"), "callback must carry an authorization code"), + verifier, + }; +} + +async function completeOauthCodeFlow({ + asUrl, + accessMode = "continuous", + client, + manifest, +}: { + asUrl: string; + accessMode?: "continuous" | "single_use"; + client: RegisteredClient; + manifest: ConnectorManifest; +}): Promise<OauthCodeFlowResult> { + const { code, verifier } = await prepareOauthCodeFlow({ accessMode, asUrl, client, manifest }); const { status, body } = await fetchJson(`${asUrl}/oauth/token`, { body: new URLSearchParams({ @@ -404,24 +509,26 @@ async function completeOauthCodeFlow({ }); assert.equal(status, 200); assert.equal(body.token_type, "Bearer"); - assert.equal(Number.isInteger(body.expires_in), true); - assert.ok((body.expires_in as number) > 0); assert.ok(body.access_token); return { accessToken: stringField(body, "access_token"), code, + expiresIn: typeof body.expires_in === "number" ? body.expires_in : null, grantId: body.grant_id as string | undefined, refreshToken: (body.refresh_token as string | undefined) || null, }; } -function hostedMcpAuthorizationDetails(manifest: ConnectorManifest): Record<string, unknown>[] { +function hostedMcpAuthorizationDetails( + manifest: ConnectorManifest, + accessMode: "continuous" | "single_use" = "continuous" +): Record<string, unknown>[] { return [ { - access_mode: "continuous", + access_mode: accessMode, purpose_code: "https://pdpp.dev/purpose/personal_ai_assistant", purpose_description: "Use PDPP data through hosted MCP.", - source: { id: manifest.connector_id, kind: "connector" }, + source: { id: publicSourceIdForManifest(manifest), kind: "connector" }, streams: [{ name: "*" }], type: "https://pdpp.dev/data-access", }, @@ -508,7 +615,7 @@ async function completeMultiSourcePackageFlow({ "picker MUST NOT submit raw connection:<id>:<id> selection values" ); for (const id of connectorIds) { - const encoded = encodeHostedMcpSelection({ connectionId: null, connectorId: id }); + const encoded = encodeHostedMcpSelection({ connectionId: defaultHostedInstanceId(id), connectorId: id }); assert.ok(pickerHtml.includes(`value="${encoded}"`), `picker should advertise opaque selection for ${id}`); } @@ -528,7 +635,10 @@ async function completeMultiSourcePackageFlow({ params.append("code_challenge", challenge); params.append("code_challenge_method", "S256"); for (const id of connectorIds) { - params.append("selection", encodeHostedMcpSelection({ connectionId: null, connectorId: id })); + params.append( + "selection", + encodeHostedMcpSelection({ connectionId: defaultHostedInstanceId(id), connectorId: id }) + ); } for (const streamValue of renderedHostedMcpStreamValues(pickerHtml)) { params.append("stream", streamValue); @@ -540,7 +650,9 @@ async function completeMultiSourcePackageFlow({ method: "POST", redirect: "manual", }); - assert.equal(approveResp.status, 302); + if (approveResp.status !== 302) { + assert.fail(`expected approval redirect, got ${approveResp.status}: ${await approveResp.text()}`); + } const callback = new URL( mustExist(approveResp.headers.get("location"), "approve redirect must carry a Location header") ); @@ -563,6 +675,7 @@ async function completeMultiSourcePackageFlow({ assert.equal(body.token_type, "Bearer"); assert.equal(Number.isInteger(body.expires_in), true); assert.ok((body.expires_in as number) > 0); + assert.ok((body.expires_in as number) <= 600, "refresh-capable package access token is short-lived"); assert.ok(body.access_token); assert.ok(body.grant_package_id, "multi-source approval issues a package-bound token"); assert.equal(body.grant_id, undefined, "package tokens MUST NOT carry a child grant_id at the OAuth surface"); @@ -643,6 +756,7 @@ function startOpenTestServer(): Promise<CloseableTestServer> { ownerAuthPassword: "", quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); } @@ -658,10 +772,11 @@ test("hosted MCP OAuth code flow issues a scoped client token usable at /mcp", a const rsUrl = `http://localhost:${server.rsPort}`; try { - const manifest = await registerSpotify(asUrl); + const manifest = await registerAuthorizedSpotify(asUrl); const client = await registerAuthCodeClient(asUrl); const { accessToken, + expiresIn, refreshToken: maybeRefreshToken, grantId: maybeGrantId, code, @@ -673,6 +788,7 @@ test("hosted MCP OAuth code flow issues a scoped client token usable at /mcp", a const refreshToken = mustExist(maybeRefreshToken, "authorization code flow must issue a refresh token"); const grantId = mustExist(maybeGrantId, "authorization code flow must issue a grant id"); assert.equal(refreshToken.startsWith("rt_"), true); + assert.ok(expiresIn !== null && expiresIn > 0 && expiresIn <= 600, "code access token reports its short lifetime"); const reused = await fetchJson(`${asUrl}/oauth/token`, { body: new URLSearchParams({ @@ -682,7 +798,7 @@ test("hosted MCP OAuth code flow issues a scoped client token usable at /mcp", a grant_type: "authorization_code", redirect_uri: "https://client.example/callback", }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", }); assert.equal(reused.status, 400); @@ -694,24 +810,26 @@ test("hosted MCP OAuth code flow issues a scoped client token usable at /mcp", a grant_type: "refresh_token", refresh_token: refreshToken, }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", }); assert.equal(refreshed.status, 200); assert.equal(refreshed.body.token_type, "Bearer"); assert.equal(Number.isInteger(refreshed.body.expires_in), true); assert.ok((refreshed.body.expires_in as number) > 0); - assert.equal(refreshed.body.refresh_token, refreshToken); + assert.ok((refreshed.body.expires_in as number) <= 600); + assert.notEqual(refreshed.body.refresh_token, refreshToken); assert.equal(refreshed.body.grant_id, grantId); assert.ok(refreshed.body.access_token); assert.notEqual(refreshed.body.access_token, accessToken); const refreshedAccessToken = stringField(refreshed.body, "access_token"); + const rotatedRefreshToken = stringField(refreshed.body, "refresh_token"); const wrongClient = await fetchJson(`${asUrl}/oauth/token`, { body: new URLSearchParams({ client_id: "cli_wrong", grant_type: "refresh_token", - refresh_token: refreshToken, + refresh_token: rotatedRefreshToken, }).toString(), headers: { "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", @@ -729,7 +847,7 @@ test("hosted MCP OAuth code flow issues a scoped client token usable at /mcp", a protocolVersion: "2025-06-18", }, }); - assert.equal(initialize.status, 200); + assert.equal(initialize.status, 200, JSON.stringify(initialize.body)); const initializeServerInfo = resultOf(initialize).serverInfo as Record<string, unknown>; assert.equal(initializeServerInfo.name, "pdpp-reference-mcp"); assert.deepEqual(initializeServerInfo.icons, [ @@ -753,7 +871,6 @@ test("hosted MCP OAuth code flow issues a scoped client token usable at /mcp", a toolNames.some((name) => name.includes("event_subscription")), false ); - const refreshedTools = await postMcpJson(rsUrl, refreshedAccessToken, { id: 22, jsonrpc: "2.0", @@ -789,15 +906,590 @@ test("hosted MCP OAuth code flow issues a scoped client token usable at /mcp", a body: new URLSearchParams({ client_id: client.client_id, grant_type: "refresh_token", - refresh_token: refreshToken, + refresh_token: rotatedRefreshToken, }).toString(), headers: { "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", }); assert.equal(afterRevoke.status, 400); assert.equal(afterRevoke.body.error, "invalid_grant"); + + const replayFlow = await completeOauthCodeFlow({ asUrl, client, manifest }); + const replayedRefreshToken = mustExist(replayFlow.refreshToken, "replay flow must issue a refresh token"); + const firstRotation = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: replayedRefreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(firstRotation.status, 200); + const successorRefreshToken = stringField(firstRotation.body, "refresh_token"); + assert.notEqual(successorRefreshToken, replayedRefreshToken); + + const replayed = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: replayedRefreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(replayed.status, 400); + assert.equal(replayed.body.error, "invalid_grant"); + assert.equal(replayed.body.fresh_authorization_required, true); + + const replayFamilyId = ( + getDb() + .prepare("SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = ?") + .get(createHash("sha256").update(replayedRefreshToken).digest("base64url")) as { family_id: string } + ).family_id; + const replayFamilyBearers = getDb() + .prepare( + `SELECT token_id, expires_at, revoked + FROM tokens + WHERE refresh_family_id = ? + ORDER BY created_at, token_id` + ) + .all(replayFamilyId) as Array<{ expires_at: string; revoked: number; token_id: string }>; + assert.equal(replayFamilyBearers.length, 2, "the initial and attacker-minted bearer are linked to the family"); + for (const bearer of replayFamilyBearers) { + assert.equal(bearer.revoked, 1, "replay revokes every family-linked bearer row"); + const lifetimeSeconds = (Date.parse(bearer.expires_at) - Date.now()) / 1000; + assert.ok(lifetimeSeconds > 0 && lifetimeSeconds <= 600, "every family bearer has a short token-specific expiry"); + // biome-ignore lint/performance/noAwaitInLoops: The attacker-first oracle introspects every family bearer. + assert.equal((await introspectAccessToken(asUrl, bearer.token_id)).active, false); + } + + const successorAfterReplay = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: successorRefreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(successorAfterReplay.status, 400); + assert.equal(successorAfterReplay.body.error, "invalid_grant"); + + const concurrentFlow = await completeOauthCodeFlow({ asUrl, client, manifest }); + const concurrentRefreshToken = mustExist( + concurrentFlow.refreshToken, + "SQLite concurrency flow must issue a refresh token" + ); + const exchangeConcurrently = () => + fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: concurrentRefreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + const concurrentResults = await Promise.all([exchangeConcurrently(), exchangeConcurrently()]); + assert.deepEqual(concurrentResults.map(({ status }) => status).sort(), [200, 400]); + const concurrentFailure = concurrentResults.find(({ status }) => status === 400); + assert.equal(concurrentFailure?.body.error, "invalid_grant"); + assert.equal(concurrentFailure?.body.fresh_authorization_required, true); + const familyRows = getDb() + .prepare( + `SELECT generation, status + FROM oauth_refresh_tokens + WHERE family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = ? + ) + ORDER BY generation` + ) + .all(createHash("sha256").update(concurrentRefreshToken).digest("base64url")) as Array<{ + generation: number; + status: string; + }>; + assert.deepEqual(familyRows, [ + { generation: 0, status: "revoked" }, + { generation: 1, status: "revoked" }, + ]); + } finally { + await closeServer(server); + } +}); + +test("OAuth token lifetime and refresh eligibility follow the persisted grant contract", async () => { + const server = await startOpenTestServer(); + const asUrl = `http://localhost:${server.asPort}`; + try { + const manifest = await registerAuthorizedSpotify(asUrl); + + const noRefreshClient = await registerAuthCodeClient(asUrl, { refreshToken: false }); + const continuous = await completeOauthCodeFlow({ asUrl, client: noRefreshClient, manifest }); + assert.equal(continuous.refreshToken, null); + assert.equal(continuous.expiresIn, null, "expires_in is omitted when the persisted access token has no expiry"); + const continuousIntrospection = await introspectAccessToken(asUrl, continuous.accessToken); + assert.equal(continuousIntrospection.active, true); + assert.equal(Object.hasOwn(continuousIntrospection, "exp"), false, "RFC 7662 exp is omitted when absent"); + + const refreshCapableClient = await registerAuthCodeClient(asUrl); + const singleUse = await completeOauthCodeFlow({ + accessMode: "single_use", + asUrl, + client: refreshCapableClient, + manifest, + }); + assert.equal(singleUse.refreshToken, null, "single_use grants never issue refresh tokens"); + assert.ok(singleUse.expiresIn !== null && singleUse.expiresIn > 0, "single_use reports its actual token expiry"); + assert.ok(singleUse.expiresIn <= 24 * 60 * 60); + const singleUseIntrospection = await introspectAccessToken(asUrl, singleUse.accessToken); + assert.equal(typeof singleUseIntrospection.exp, "number"); + } finally { + await closeServer(server); + } +}); + +test("SQLite authorization-code failure rolls back consumption with initial refresh issuance", async () => { + const server = await startOpenTestServer(); + const asUrl = `http://localhost:${server.asPort}`; + try { + const manifest = await registerAuthorizedSpotify(asUrl); + const client = await registerAuthCodeClient(asUrl); + const prepared = await prepareOauthCodeFlow({ asUrl, client, manifest }); + getDb().exec(` + CREATE TRIGGER fail_initial_refresh_issuance + BEFORE INSERT ON oauth_refresh_tokens + BEGIN + SELECT RAISE(ABORT, 'injected initial refresh failure'); + END + `); + const redeem = () => + fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + code: prepared.code, + code_verifier: prepared.verifier, + grant_type: "authorization_code", + redirect_uri: "https://client.example/callback", + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + + const failed = await redeem(); + assert.notEqual(failed.status, 200); + const afterFailure = getDb() + .prepare("SELECT status, consumed_at FROM oauth_authorization_codes WHERE code = ?") + .get(prepared.code) as { consumed_at: string | null; status: string }; + assert.deepEqual(afterFailure, { consumed_at: null, status: "issued" }); + assert.equal( + (getDb().prepare("SELECT COUNT(*) AS count FROM oauth_refresh_tokens").get() as { count: number }).count, + 0 + ); + + getDb().exec("DROP TRIGGER fail_initial_refresh_issuance"); + const retried = await redeem(); + assert.equal(retried.status, 200); + assert.equal(typeof retried.body.refresh_token, "string"); + } finally { + await closeServer(server); + } +}); + +test("pre-family SQLite refresh rows are rejected without reconstruction", async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), "pdpp-refresh-legacy-")); + const dbPath = join(tempDirectory, "legacy.sqlite"); + const legacy = new Database(dbPath); + legacy.exec(` + CREATE TABLE oauth_refresh_tokens ( + refresh_token_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + grant_id TEXT, + package_id TEXT, + subject_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + expires_at TEXT, + last_used_at TEXT, + revoked_at TEXT + ) + `); + legacy.close(); + + const server = await startServer({ + asPort: 0, + dbPath, + ownerAuthPassword: "", + quiet: true, + rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, + }); + const asUrl = `http://localhost:${server.asPort}`; + try { + const client = await registerAuthCodeClient(asUrl); + const legacyRefreshToken = `rt_${randomBytes(32).toString("base64url")}`; + const legacyHash = createHash("sha256").update(legacyRefreshToken).digest("base64url"); + getDb() + .prepare( + `INSERT INTO oauth_refresh_tokens( + refresh_token_hash, client_id, grant_id, subject_id, status, created_at + ) VALUES(?, ?, ?, ?, 'active', ?)` + ) + .run(legacyHash, client.client_id, "grt_legacy", "owner_local", new Date().toISOString()); + + const response = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: legacyRefreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(response.status, 400); + assert.equal(response.body.error, "invalid_grant"); + const row = getDb() + .prepare( + `SELECT family_id, generation, parent_generation, status + FROM oauth_refresh_tokens + WHERE refresh_token_hash = ?` + ) + .get(legacyHash) as Record<string, unknown>; + assert.deepEqual(row, { + family_id: null, + generation: null, + parent_generation: null, + status: "active", + }); + + const failedIssuanceToken = `rt_${randomBytes(32).toString("base64url")}`; + const failedIssuanceHash = createHash("sha256").update(failedIssuanceToken).digest("base64url"); + getDb() + .prepare( + `INSERT INTO oauth_refresh_tokens( + refresh_token_hash, family_id, generation, parent_generation, client_id, + grant_id, subject_id, status, created_at + ) VALUES(?, ?, 0, NULL, ?, ?, ?, 'active', ?)` + ) + .run( + failedIssuanceHash, + "rtf_failed_access_issuance", + client.client_id, + "grt_missing_for_fault_injection", + "owner_local", + new Date().toISOString() + ); + const failedIssuance = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: failedIssuanceToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.notEqual(failedIssuance.status, 200); + const failedFamilyRows = getDb() + .prepare("SELECT status FROM oauth_refresh_tokens WHERE family_id = ?") + .all("rtf_failed_access_issuance") as Array<{ status: string }>; + assert.ok(failedFamilyRows.length >= 1); + assert.equal( + failedFamilyRows.every(({ status }) => status === "revoked"), + true, + "access-token issuance failure revokes every persisted refresh generation" + ); + } finally { + await closeServer(server); + closeDb(); + rmSync(tempDirectory, { force: true, recursive: true }); + } +}); + +test("SQLite migration revokes unlinked legacy refresh families and their bound bearers", () => { + const tempDirectory = mkdtempSync(join(tmpdir(), "pdpp-refresh-family-migration-")); + const dbPath = join(tempDirectory, "legacy-family.sqlite"); + try { + initDb(dbPath); + getDb() + .prepare( + `INSERT INTO tokens(token_id, grant_id, subject_id, client_id, token_kind) + VALUES('tok_legacy_family', 'grt_legacy_family', 'owner_local', 'client_legacy', 'client')` + ) + .run(); + getDb() + .prepare( + `INSERT INTO oauth_refresh_tokens( + refresh_token_hash, family_id, generation, client_id, grant_id, + subject_id, status, created_at + ) VALUES('hash_legacy_family', 'rtf_legacy_family', 0, 'client_legacy', + 'grt_legacy_family', 'owner_local', 'active', ?)` + ) + .run(new Date().toISOString()); + closeDb(); + + initDb(dbPath); + const refresh = getDb() + .prepare("SELECT status, revoked_at FROM oauth_refresh_tokens WHERE family_id = 'rtf_legacy_family'") + .get() as { revoked_at: string | null; status: string }; + const bearer = getDb() + .prepare("SELECT refresh_family_id, revoked FROM tokens WHERE token_id = 'tok_legacy_family'") + .get() as { refresh_family_id: string | null; revoked: number }; + assert.equal(refresh.status, "revoked", "unlinked pre-migration family requires fresh authorization"); + assert.ok(refresh.revoked_at); + assert.deepEqual(bearer, { refresh_family_id: null, revoked: 1 }); + } finally { + closeDb(); + rmSync(tempDirectory, { force: true, recursive: true }); + } +}); + +test("SQLite refresh failure rolls back rotation and bearer issuance together", async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), "pdpp-refresh-fail-closed-")); + const dbPath = join(tempDirectory, "refresh.sqlite"); + const server = await startServer({ + asPort: 0, + dbPath, + ownerAuthPassword: "", + quiet: true, + rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, + }); + const asUrl = `http://localhost:${server.asPort}`; + try { + const manifest = await registerSpotify(asUrl); + await seedDefaultHostedInstance(manifest); + const client = await registerAuthCodeClient(asUrl); + const issued = await completeOauthCodeFlow({ asUrl, client, manifest }); + const refreshToken = mustExist(issued.refreshToken, "fault flow must issue a refresh token"); + const grantId = mustExist(issued.grantId, "fault flow must issue a grant id"); + const activeBefore = getDb() + .prepare("SELECT COUNT(*) AS count FROM tokens WHERE grant_id = ? AND revoked = 0") + .get(grantId) as { count: number }; + + getDb().exec(` + CREATE TRIGGER fail_refresh_token_issued_event + BEFORE INSERT ON spine_events + WHEN NEW.event_type = 'token.issued' + AND json_extract(NEW.data_json, '$.issuance_path') = 'oauth_refresh_token' + BEGIN + SELECT RAISE(ABORT, 'injected refresh token event failure'); + END + `); + + const failed = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: refreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(failed.status, 400); + + const family = getDb() + .prepare( + `SELECT generation, status + FROM oauth_refresh_tokens + WHERE family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = ? + ) + ORDER BY generation` + ) + .all(createHash("sha256").update(refreshToken).digest("base64url")) as Array<{ + generation: number; + status: string; + }>; + assert.deepEqual(family, [{ generation: 0, status: "active" }]); + const activeAfter = getDb() + .prepare("SELECT COUNT(*) AS count FROM tokens WHERE grant_id = ? AND revoked = 0") + .get(grantId) as { count: number }; + assert.equal(activeAfter.count, activeBefore.count, "failed refresh does not add an active bearer"); + } finally { + await closeServer(server); + closeDb(); + rmSync(tempDirectory, { force: true, recursive: true }); + } +}); + +test("SQLite refresh replay containment rolls back the family and bearers together on failure", async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), "pdpp-refresh-replay-fail-closed-")); + const server = await startServer({ + asPort: 0, + dbPath: join(tempDirectory, "refresh.sqlite"), + ownerAuthPassword: "", + quiet: true, + rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, + }); + const asUrl = `http://localhost:${server.asPort}`; + try { + const manifest = await registerSpotify(asUrl); + await seedDefaultHostedInstance(manifest); + const client = await registerAuthCodeClient(asUrl); + const issued = await completeOauthCodeFlow({ asUrl, client, manifest }); + const generationZero = mustExist(issued.refreshToken, "replay-fault flow must issue generation zero"); + const rotated = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: generationZero, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(rotated.status, 200); + const generationOne = stringField(rotated.body, "refresh_token"); + + getDb().exec(` + CREATE TRIGGER fail_family_bearer_revoke + BEFORE UPDATE OF revoked ON tokens + WHEN OLD.revoked = 0 AND NEW.revoked = 1 AND NEW.refresh_family_id IS NOT NULL + BEGIN + SELECT RAISE(ABORT, 'injected family bearer revoke failure'); + END + `); + try { + const failedReplay = await fetch(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: generationZero, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.notEqual(failedReplay.status, 200, "failed bearer revoke cannot commit partial containment"); + } finally { + getDb().exec("DROP TRIGGER IF EXISTS fail_family_bearer_revoke"); + } + + const refreshHash = createHash("sha256").update(generationZero).digest("base64url"); + const family = getDb() + .prepare( + `SELECT generation, status + FROM oauth_refresh_tokens + WHERE family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = ? + ) + ORDER BY generation` + ) + .all(refreshHash) as Array<{ generation: number; status: string }>; + assert.deepEqual(family, [ + { generation: 0, status: "superseded" }, + { generation: 1, status: "active" }, + ]); + const bearers = getDb() + .prepare( + `SELECT revoked + FROM tokens + WHERE refresh_family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = ? + ) + ORDER BY created_at, token_id` + ) + .all(refreshHash) as Array<{ revoked: number }>; + assert.deepEqual( + bearers.map(({ revoked }) => revoked), + [0, 0], + "failed containment rolls bearer revocation back atomically" + ); + + const successor = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: generationOne, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(successor.status, 200, "rolled-back successor remains usable"); + } finally { + await closeServer(server); + closeDb(); + rmSync(tempDirectory, { force: true, recursive: true }); + } +}); + +test("SQLite supersede failure rolls back the newly inserted family bearer", async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), "pdpp-refresh-supersede-fail-")); + const server = await startServer({ + asPort: 0, + dbPath: join(tempDirectory, "refresh.sqlite"), + ownerAuthPassword: "", + quiet: true, + rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, + }); + const asUrl = `http://localhost:${server.asPort}`; + try { + const manifest = await registerSpotify(asUrl); + await seedDefaultHostedInstance(manifest); + const client = await registerAuthCodeClient(asUrl); + const issued = await completeOauthCodeFlow({ asUrl, client, manifest }); + const generationZero = mustExist(issued.refreshToken, "supersede-fault flow must issue generation zero"); + const refreshHash = createHash("sha256").update(generationZero).digest("base64url"); + + getDb().exec(` + CREATE TRIGGER fail_refresh_supersede + BEFORE UPDATE OF status ON oauth_refresh_tokens + WHEN OLD.status = 'active' AND NEW.status = 'superseded' + BEGIN + SELECT RAISE(ABORT, 'injected refresh supersede failure'); + END + `); + try { + const failure = await fetch(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: generationZero, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.notEqual(failure.status, 200); + } finally { + getDb().exec("DROP TRIGGER IF EXISTS fail_refresh_supersede"); + } + + const family = getDb() + .prepare( + `SELECT generation, status + FROM oauth_refresh_tokens + WHERE family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = ? + ) + ORDER BY generation` + ) + .all(refreshHash) as Array<{ generation: number; status: string }>; + assert.deepEqual(family, [{ generation: 0, status: "active" }]); + const bearers = getDb() + .prepare( + `SELECT revoked + FROM tokens + WHERE refresh_family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = ? + )` + ) + .all(refreshHash) as Array<{ revoked: number }>; + assert.deepEqual(bearers, [{ revoked: 0 }], "failed supersede leaves no orphan refresh-derived bearer"); + + const retried = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: generationZero, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(retried.status, 200, "generation zero remains usable after rollback"); } finally { await closeServer(server); + closeDb(); + rmSync(tempDirectory, { force: true, recursive: true }); } }); @@ -966,7 +1658,7 @@ test("grant-scoped MCP device authorization issues a client token usable at /mcp const rsUrl = `http://localhost:${server.rsPort}`; try { - const manifest = await registerSpotify(asUrl); + const manifest = await registerAuthorizedSpotify(asUrl); const client = await registerAuthCodeClient(asUrl); const device = await startMcpDeviceAuthorization({ asUrl, client, manifest, rsUrl }); @@ -1006,12 +1698,13 @@ test("grant-scoped MCP device authorization issues a client token usable at /mcp assert.equal(tooFast.status, 400); assert.equal(tooFast.body.error, "slow_down"); + const reviewRevision = await reviewConsent(asUrl, buildPendingConsentRequestUri(deviceCode)); const approveResp = await fetch(`${asUrl}/consent/approve`, { body: new URLSearchParams({ + approval_review_revision: reviewRevision, request_uri: buildPendingConsentRequestUri(deviceCode), - subject_id: "owner_local", }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", redirect: "manual", }); @@ -1031,11 +1724,15 @@ test("grant-scoped MCP device authorization issues a client token usable at /mcp assert.ok(token.body.access_token); assert.ok(token.body.grant_id); assert.equal(token.body.grant_package_id, undefined); + assert.equal(token.body.expires_in, undefined, "device token response omits an expiry absent from storage"); const tokenAccessToken = stringField(token.body, "access_token"); const introspected = await fetchJson(`${asUrl}/introspect`, { body: new URLSearchParams({ token: tokenAccessToken }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { + Authorization: INTROSPECTION_AUTHORIZATION, + "Content-Type": "application/x-www-form-urlencoded", + }, method: "POST", }); assert.equal(introspected.status, 200); @@ -1043,6 +1740,7 @@ test("grant-scoped MCP device authorization issues a client token usable at /mcp assert.equal(introspected.body.pdpp_token_kind, "client"); assert.equal(introspected.body.client_id, client.client_id); assert.equal(introspected.body.grant_id, token.body.grant_id); + assert.equal(Object.hasOwn(introspected.body, "exp"), false, "introspection omits an absent expiry"); const tools = await postMcpJson(rsUrl, tokenAccessToken, { id: 2, @@ -1300,7 +1998,7 @@ test("CIMD native loopback redirect matching ignores only runtime port", async ( const asUrl = `http://localhost:${server.asPort}`; try { - const spotify = await registerSpotify(asUrl); + const spotify = await registerAuthorizedSpotify(asUrl); const client = await createCimdClientDocument(asUrl, { client_name: "Claude Code", redirect_uris: ["http://localhost/callback", "http://127.0.0.1/callback"], @@ -1342,13 +2040,14 @@ test("CIMD native loopback redirect matching ignores only runtime port", async ( ); const requestUri = consentUrl.searchParams.get("request_uri"); assert.ok(requestUri); + const reviewRevision = await reviewConsent(asUrl, requestUri); const approveResp = await fetch(`${asUrl}/consent/approve`, { body: new URLSearchParams({ + approval_review_revision: reviewRevision, request_uri: requestUri, - subject_id: "owner_local", }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", redirect: "manual", }); @@ -1408,8 +2107,8 @@ test("multi-source hosted MCP picker issues a package token usable at /mcp with const rsUrl = `http://localhost:${server.rsPort}`; try { - const spotify = await registerSpotify(asUrl); - const github = await registerGithub(asUrl); + const spotify = await registerAuthorizedSpotify(asUrl); + const github = await registerAuthorizedGithub(asUrl); const client = await registerAuthCodeClient(asUrl); const { accessToken, refreshToken, packageId } = await completeMultiSourcePackageFlow({ @@ -1458,7 +2157,8 @@ test("multi-source hosted MCP picker issues a package token usable at /mcp with schemaStreams .map((s) => { const source = s.source as Record<string, unknown> | undefined; - return source?.connector_id || source?.connector_key; + const connectorId = source?.connector_id || source?.connector_key; + return typeof connectorId === "string" ? (canonicalConnectorKey(connectorId) ?? connectorId) : connectorId; }) .filter(Boolean) ); @@ -1521,14 +2221,77 @@ test("multi-source hosted MCP picker issues a package token usable at /mcp with } }); +test("package refresh replay deactivates every family-linked package bearer", async () => { + const server = await startOpenTestServer(); + const asUrl = `http://localhost:${server.asPort}`; + try { + const spotify = await registerAuthorizedSpotify(asUrl); + const github = await registerAuthorizedGithub(asUrl); + const client = await registerAuthCodeClient(asUrl); + const issued = await completeMultiSourcePackageFlow({ + asUrl, + client, + connectorIds: [spotify.connector_id, github.connector_id], + }); + const initialRefreshToken = mustExist(issued.refreshToken, "continuous package issues refresh"); + const attackerRotation = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: initialRefreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(attackerRotation.status, 200); + const attackerAccessToken = stringField(attackerRotation.body, "access_token"); + + const legitimateReplay = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: initialRefreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(legitimateReplay.status, 400); + assert.equal(legitimateReplay.body.fresh_authorization_required, true); + + const familyId = ( + getDb() + .prepare("SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = ?") + .get(createHash("sha256").update(initialRefreshToken).digest("base64url")) as { family_id: string } + ).family_id; + const familyBearers = getDb() + .prepare("SELECT token_id, token_kind, revoked FROM tokens WHERE refresh_family_id = ? ORDER BY token_id") + .all(familyId) as Array<{ revoked: number; token_id: string; token_kind: string }>; + assert.deepEqual( + new Set(familyBearers.map((bearer) => bearer.token_kind)), + new Set(["mcp_package"]), + "package refresh families contain package bearers only" + ); + assert.equal(familyBearers.length, 2); + assert.ok(familyBearers.some((bearer) => bearer.token_id === issued.accessToken)); + assert.ok(familyBearers.some((bearer) => bearer.token_id === attackerAccessToken)); + for (const bearer of familyBearers) { + assert.equal(bearer.revoked, 1); + // biome-ignore lint/performance/noAwaitInLoops: The containment oracle introspects every package bearer. + assert.equal((await introspectAccessToken(asUrl, bearer.token_id)).active, false); + } + } finally { + await closeServer(server); + } +}); + test("revoking one child grant silently removes that source from the package /mcp fanout", async () => { const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; try { - const spotify = await registerSpotify(asUrl); - const github = await registerGithub(asUrl); + const spotify = await registerAuthorizedSpotify(asUrl); + const github = await registerAuthorizedGithub(asUrl); const client = await registerAuthCodeClient(asUrl); const { accessToken, packageId } = await completeMultiSourcePackageFlow({ @@ -1560,7 +2323,7 @@ test("revoking one child grant silently removes that source from the package /mc const beforePackage = mustExist(schemaPackageMetadata(beforeData), "schema response carries package metadata"); assert.equal(beforePackage.member_count, 2); const childGrants = beforePackage.sources.map((s) => ({ - connector_id: s.connector_id, + connector_id: canonicalConnectorKey(s.connector_id) ?? s.connector_id, grant_id: s.grant_id, })); const spotifyChild = mustExist( @@ -1584,13 +2347,20 @@ test("revoking one child grant silently removes that source from the package /mc const afterData = structuredContentData(resultOf(after)); const afterPackage = mustExist(schemaPackageMetadata(afterData), "schema response carries package metadata"); assert.equal(afterPackage.member_count, 1, "revoked child is no longer counted in the package fanout"); - const afterConnectorIds = new Set(schemaStreamRows(afterData).map((s) => s.source?.connector_id)); + const afterConnectorIds = new Set( + schemaStreamRows(afterData).map((stream) => { + const connectorId = stream.source?.connector_id; + return canonicalConnectorKey(connectorId) ?? connectorId; + }) + ); assert.ok( !afterConnectorIds.has(spotify.connector_id), "spotify streams are absent after its child grant is revoked" ); assert.ok(afterConnectorIds.has(github.connector_id), "github streams still present"); - const afterSourceConnectorIds = afterPackage.sources.map((s) => s.connector_id); + const afterSourceConnectorIds = afterPackage.sources.map( + (source) => canonicalConnectorKey(source.connector_id) ?? source.connector_id + ); assert.deepEqual(afterSourceConnectorIds, [github.connector_id]); // The package token itself stays valid because the package is still @@ -1610,8 +2380,8 @@ test("revoking the package invalidates /mcp access and the refresh-token exchang const rsUrl = `http://localhost:${server.rsPort}`; try { - const spotify = await registerSpotify(asUrl); - const github = await registerGithub(asUrl); + const spotify = await registerAuthorizedSpotify(asUrl); + const github = await registerAuthorizedGithub(asUrl); const client = await registerAuthCodeClient(asUrl); const { accessToken, refreshToken, packageId } = await completeMultiSourcePackageFlow({ @@ -1938,7 +2708,7 @@ async function exchangePackageCode({ }): Promise<Response> { const approveResp = await fetch(`${asUrl}/oauth/authorize/mcp-package`, { body: params.toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", redirect: "manual", }); @@ -2071,7 +2841,7 @@ test("hosted MCP picker pre-selects nothing: zero checked sources and zero check const asUrl = `http://localhost:${server.asPort}`; try { - await registerSpotify(asUrl); + await registerAuthorizedSpotify(asUrl); await registerGithub(asUrl); const client = await registerAuthCodeClient(asUrl); const verifier = randomBytes(32).toString("base64url"); @@ -2118,8 +2888,8 @@ test("POST /oauth/authorize/mcp-package narrows the child grant to the submitted const asUrl = `http://localhost:${server.asPort}`; try { - const spotify = await registerSpotify(asUrl); - const github = await registerGithub(asUrl); + const spotify = await registerAuthorizedSpotify(asUrl); + const github = await registerAuthorizedGithub(asUrl); const client = await registerAuthCodeClient(asUrl); const verifier = randomBytes(32).toString("base64url"); @@ -2172,7 +2942,9 @@ test("POST /oauth/authorize/mcp-package narrows the child grant to the submitted const access = mustExist(await getGrantPackageAccess(packageId), "package access must exist") as GrantPackageAccess; assert.ok(access, "package is retrievable after issuance"); assert.equal(access.members.length, 2); - const byConnector = new Map(access.members.map((m) => [m.grant.source.id, m])); + const byConnector = new Map( + access.members.map((member) => [canonicalConnectorKey(member.grant.source.id), member]) + ); const spotifyChild = byConnector.get(spotify.connector_id); const githubChild = byConnector.get(github.connector_id); assert.ok(spotifyChild && githubChild, "one child per approved connector"); @@ -2206,7 +2978,7 @@ test("POST /oauth/authorize/mcp-package preserves the wildcard when every stream const asUrl = `http://localhost:${server.asPort}`; try { - const spotify = await registerSpotify(asUrl); + const spotify = await registerAuthorizedSpotify(asUrl); const client = await registerAuthCodeClient(asUrl); const verifier = randomBytes(32).toString("base64url"); @@ -2397,8 +3169,8 @@ test("POST /oauth/authorize/mcp-package ignores stream entries whose source was const asUrl = `http://localhost:${server.asPort}`; try { - const spotify = await registerSpotify(asUrl); - const github = await registerGithub(asUrl); + const spotify = await registerAuthorizedSpotify(asUrl); + const github = await registerAuthorizedGithub(asUrl); const client = await registerAuthCodeClient(asUrl); const verifier = randomBytes(32).toString("base64url"); @@ -2466,7 +3238,10 @@ test("POST /oauth/authorize/mcp-package ignores stream entries whose source was "package access must exist" ) as GrantPackageAccess; assert.equal(access.members.length, 1, "orphan stream entries MUST NOT create a child grant"); - assert.equal(mustExist(access.members[0], "package must carry one member").grant.source.id, spotify.connector_id); + assert.equal( + mustExist(access.members[0], "package must carry one member").grant.source.id, + publicSourceIdForManifest(spotify) + ); } finally { await closeServer(server); } @@ -2543,8 +3318,8 @@ test("POST /oauth/authorize/mcp-package narrows every child grant to single_use const asUrl = `http://localhost:${server.asPort}`; try { - const spotify = await registerSpotify(asUrl); - const github = await registerGithub(asUrl); + const spotify = await registerAuthorizedSpotify(asUrl); + const github = await registerAuthorizedGithub(asUrl); const client = await registerAuthCodeClient(asUrl); const verifier = randomBytes(32).toString("base64url"); @@ -2583,6 +3358,11 @@ test("POST /oauth/authorize/mcp-package narrows every child grant to single_use method: "POST", }); assert.equal(status, 200); + assert.equal(body.refresh_token, undefined, "a package containing single_use grants never issues refresh tokens"); + assert.equal(body.expires_in, undefined, "the response omits expires_in when the package bearer has no expiry"); + const introspection = await introspectAccessToken(asUrl, stringField(body, "access_token")); + assert.equal(introspection.active, true); + assert.equal(Object.hasOwn(introspection, "exp"), false, "introspection omits exp when storage has no expiry"); const access = mustExist( await getGrantPackageAccess(body.grant_package_id), "package access must exist" @@ -2605,7 +3385,7 @@ test("POST /oauth/authorize/mcp-package defaults every child grant to continuous const asUrl = `http://localhost:${server.asPort}`; try { - const spotify = await registerSpotify(asUrl); + const spotify = await registerAuthorizedSpotify(asUrl); const client = await registerAuthCodeClient(asUrl); const verifier = randomBytes(32).toString("base64url"); @@ -2697,7 +3477,7 @@ test("hosted MCP child-grant grant.issued spine event records access_mode, strea const asUrl = `http://localhost:${server.asPort}`; try { - const spotify = await registerSpotify(asUrl); + const spotify = await registerAuthorizedSpotify(asUrl); const client = await registerAuthCodeClient(asUrl); const verifier = randomBytes(32).toString("base64url"); @@ -2865,7 +3645,7 @@ test("GET /consent renders the consent page for a freshly staged pending grant", const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; try { - await registerSpotify(asUrl); + await registerAuthorizedSpotify(asUrl); const client = await registerAuthCodeClient(asUrl); // Stage a pending grant via the canonical short key path. const authorizeResp = await fetch(buildAuthorizeGetUrl({ asUrl, client, extra: { connector_id: "spotify" } }), { @@ -2880,13 +3660,14 @@ test("GET /consent renders the consent page for a freshly staged pending grant", assert.ok(requestUri?.startsWith("urn:pdpp:pending-consent:")); const consentResp = await fetch(consentUrl, { redirect: "manual" }); - assert.equal(consentResp.status, 200, "a live pending-consent request_uri must render the consent page"); const html = await consentResp.text(); + assert.equal(consentResp.status, 200, "a live pending-consent request_uri must render the consent page"); assert.ok(html.includes("<!DOCTYPE html>"), "consent page is a full hosted document"); assert.ok( - /action="\/consent\/approve"/.test(html), - "consent page must offer the approve action bound to this request_uri" + /action="\/consent\/review"/.test(html), + "consent page must require review before approval for this request_uri" ); + assert.doesNotMatch(html, /action="\/consent\/approve"/, "an unreviewed request must not offer final approval"); } finally { await closeServer(server); } @@ -2927,7 +3708,7 @@ test("GET /oauth/authorize?connector_id=<URL> stages pending consent with canoni const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; try { - await registerSpotify(asUrl); + await registerAuthorizedSpotify(asUrl); const client = await registerAuthCodeClient(asUrl); const verifier = randomBytes(32).toString("base64url"); const url = new URL(`${asUrl}/oauth/authorize`); @@ -2953,20 +3734,32 @@ test("GET /oauth/authorize?connector_id=<URL> stages pending consent with canoni const requestUri = mustExist(consentUrl.searchParams.get("request_uri"), "redirect must carry request_uri"); const ownerToken = await issueOwnerToken(asUrl); + const authorization = `Bearer ${ownerToken}`; + const reviewRevision = await reviewConsent(asUrl, requestUri, "owner_local", authorization); + // POST /consent/approve const approveParams = new URLSearchParams(); + approveParams.set("approval_review_revision", reviewRevision); approveParams.set("request_uri", requestUri); - approveParams.set("approved", "true"); const approveResp = await fetch(`${asUrl}/consent/approve`, { body: approveParams.toString(), - headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/x-www-form-urlencoded" }, + headers: { + Accept: "text/html", + Authorization: authorization, + "Content-Type": "application/x-www-form-urlencoded", + }, method: "POST", redirect: "manual", }); - // /consent/approve redirects; we just need the code + if (approveResp.status !== 302) { + assert.fail(`expected consent approval redirect, got ${approveResp.status}: ${await approveResp.text()}`); + } const codeLocation = approveResp.headers.get("location") || ""; const codeUrl = new URL(codeLocation, asUrl); - const code = mustExist(codeUrl.searchParams.get("code"), "redirect must carry an authorization code"); + const code = mustExist( + codeUrl.searchParams.get("code"), + `redirect must carry an authorization code: ${codeLocation}` + ); assert.ok(code, "approval must issue an authorization code"); // Exchange the code for a token @@ -3032,11 +3825,13 @@ test("hosted MCP picker excludes internal/test/stub connectors", async () => { const stubManifest = { connector_id: "stream-test-stub-picker-regression", display_name: "Stream Test Stub", + manifest_uri: "https://registry.pdpp.dev/connectors/stream-test-stub-picker-regression", + protocol_version: "0.1.0", streams: [ { cursor_field: "ts", name: "events", - primary_key: "id", + primary_key: ["id"], schema: { properties: { id: { type: "string" }, @@ -3044,6 +3839,8 @@ test("hosted MCP picker excludes internal/test/stub connectors", async () => { }, type: "object", }, + selection: { fields: true, resources: false }, + semantics: "append_only", }, ], version: "0.1.0", @@ -3216,34 +4013,6 @@ test("sourceMetadata.display_name uses human-readable connection name, not raw c instanceId, "source.connection_id carries the stable connection ID for programmatic use" ); - - getDb() - .prepare("UPDATE grant_package_members SET source_json = ? WHERE package_id = ? AND grant_id = ?") - .run(...[JSON.stringify({ ...childSource, display_name: instanceId }), grantPackageId, child.grant_id]); - - const { body: legacyDetail } = await fetchJson( - `${asUrl}/_ref/grant-packages/${encodeURIComponent(grantPackageId)}` - ); - const legacyChildren = legacyDetail.children as Record<string, unknown>[]; - const legacyChildSource = mustExist(legacyChildren[0], "legacy detail must carry one child").source as Record< - string, - unknown - >; - assert.equal( - legacyChildSource.display_name, - humanDisplayName, - "owner package detail sanitizes old rows whose display_name was persisted as the raw connection ID" - ); - - const legacyAccess = mustExist( - await getGrantPackageAccess(tokenBody.grant_package_id), - "package access must exist" - ) as GrantPackageAccess; - assert.equal( - mustExist(legacyAccess.members[0], "package must carry one member").source?.display_name, - humanDisplayName, - "MCP package access sanitizes old rows whose display_name was persisted as the raw connection ID" - ); } finally { await closeServer(server); } @@ -3380,20 +4149,20 @@ test("picker hides URL-shaped default connection labels from owner-visible copy" // ─── Connection-pin: selection → enforceable grant scope ──────────────────── // // The picker validates the owner's chosen connection, but the bug the scout -// report surfaced is that the value never reached `grant.streams[].connection_id` +// report surfaced is that the value never reached `grant.streams[].instance_ids`. // — it was stored only in the package member's `source_json` (audit/display), // so a "Slack work" pick still fanned in across every Slack connection at read // time. These tests prove the enforcement parity invariant end-to-end: // -// - a connection chosen among >1 active binding pins `streams[].connection_id` +// - a connection chosen among active bindings freezes `streams[].instance_ids` // on the persisted child grant; -// - a single-connection connector keeps the field OMITTED (fan-in preserved, +// - a single-connection grant freezes its one eligible instance (fan-in preserved, // no brittle stored id, no reissuance pressure); -// - the pinned `connection_id` is enforced on the read path — a grant-scoped +// - the frozen `instance_ids` set is enforced on the read path. A grant-scoped // read under the persisted child grant excludes the unselected sibling's // records (the decisive anti-Goodhart check: `source_json` alone is the // pre-existing bug, so we run the real fan-in resolver, not metadata); -// - the wildcard stream case persists `{ name: "*", connection_id }`; +// - the wildcard stream case expands into streams with frozen `instance_ids`; // - audit metadata (`source_json.connection_id`) and the enforced grant scope // agree for the pinned member (no drift between shown and enforced). // @@ -3402,6 +4171,7 @@ test("picker hides URL-shaped default connection labels from owner-visible copy" // spotify/github fixtures (no ingestible records) cannot. const PIN_CONNECTOR_ID = "pin-fixture"; +const PIN_SOURCE_ID = "https://registry.pdpp.dev/connectors/pin-fixture"; const PIN_STREAM = "messages"; function pinConnectorManifest(): ConnectorManifest { @@ -3410,6 +4180,32 @@ function pinConnectorManifest(): ConnectorManifest { connector_id: PIN_CONNECTOR_ID, display_name: "Pin Fixture Connector", protocol_version: "0.1.0", + source_declaration: { + declaration_version: "hosted-mcp.pin-fixture.v1", + display: { name: "Pin Fixture Connector" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation/tests" }, + source: { id: PIN_SOURCE_ID, kind: "connector" }, + streams: [ + { + consent_time_field: "received_at", + cursor_field: "received_at", + name: PIN_STREAM, + primary_key: ["id"], + schema: { + properties: { + id: { type: "string" }, + received_at: { format: "date-time", type: "string" }, + subject: { type: "string" }, + }, + required: ["id", "subject", "received_at"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + ], + }, streams: [ { consent_time_field: "received_at", @@ -3519,7 +4315,9 @@ async function approvePinPackage({ method: "POST", redirect: "manual", }); - assert.equal(approveResp.status, 302); + if (approveResp.status !== 302) { + assert.fail(`expected pin approval redirect, got ${approveResp.status}: ${await approveResp.text()}`); + } const code = mustExist( new URL(mustExist(approveResp.headers.get("location"), "redirect must carry a Location header")).searchParams.get( "code" @@ -3546,7 +4344,7 @@ async function approvePinPackage({ ) as GrantPackageAccess; } -test("hosted MCP picker pins streams[].connection_id on the child grant for a connection chosen among siblings, and enforces it on reads", async () => { +test("hosted MCP picker freezes streams[].instance_ids for a selected sibling and enforces it on reads", async () => { const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; @@ -3577,15 +4375,14 @@ test("hosted MCP picker pins streams[].connection_id on the child grant for a co assert.equal(access.members.length, 1); const member = mustExist(access.members[0], "package must carry one member"); - // Criterion 1: the persisted child grant carries the selected connection_id - // on every stream entry. + // The persisted child grant carries the selected instance on every stream. const pinnedStreams = member.grant.streams.filter((s) => s.name === PIN_STREAM); assert.ok(pinnedStreams.length >= 1, "child grant carries the messages stream"); for (const stream of member.grant.streams) { - assert.equal( - stream.connection_id, - connA, - `every issued stream entry must pin connection_id=${connA}; got ${JSON.stringify(stream)}` + assert.deepEqual( + stream.instance_ids, + [connA], + `every issued stream entry must freeze instance_ids=[${connA}]; got ${JSON.stringify(stream)}` ); } @@ -3623,7 +4420,7 @@ test("hosted MCP picker pins streams[].connection_id on the child grant for a co } }); -test("hosted MCP picker omits connection_id for a single-connection connector (fan-in preserved)", async () => { +test("hosted MCP picker freezes the sole eligible instance for a single-connection connector", async () => { const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; @@ -3644,12 +4441,11 @@ test("hosted MCP picker omits connection_id for a single-connection connector (f assert.equal(access.members.length, 1); const member = mustExist(access.members[0], "package must carry one member"); - // Criterion 5: no connection_id appears where none did before. for (const stream of member.grant.streams) { - assert.equal( - "connection_id" in stream, - false, - `single-connection grant must NOT pin connection_id; got ${JSON.stringify(stream)}` + assert.deepEqual( + stream.instance_ids, + [soleConn], + `single-connection grant must freeze its sole instance; got ${JSON.stringify(stream)}` ); } @@ -3696,10 +4492,10 @@ test("hosted MCP picker pins the wildcard stream entry when the whole source is const grantedNames = member.grant.streams.map((s) => s.name).sort(); assert.deepEqual(grantedNames, [...allStreamNames].sort(), "whole-source approval covers every manifest stream"); for (const stream of member.grant.streams) { - assert.equal( - stream.connection_id, - connA, - `wildcard-expanded stream "${stream.name}" must carry the connection pin; got ${JSON.stringify(stream)}` + assert.deepEqual( + stream.instance_ids, + [connA], + `wildcard-expanded stream "${stream.name}" must freeze the selected instance; got ${JSON.stringify(stream)}` ); } } finally { diff --git a/reference-implementation/test/hosted-mcp-picker-canonical-collapse.test.ts b/reference-implementation/test/hosted-mcp-picker-canonical-collapse.test.ts index ed6d6ddbe..0bfa47629 100644 --- a/reference-implementation/test/hosted-mcp-picker-canonical-collapse.test.ts +++ b/reference-implementation/test/hosted-mcp-picker-canonical-collapse.test.ts @@ -38,6 +38,7 @@ function claudeCodeManifest(connectorId: string, displayName: string) { capabilities: { human_interaction: [] }, connector_id: connectorId, display_name: displayName, + manifest_uri: connectorId.startsWith("http") ? connectorId : "https://registry.pdpp.org/connectors/claude-code", protocol_version: "0.1.0", streams: [ { @@ -54,7 +55,8 @@ function claudeCodeManifest(connectorId: string, displayName: string) { required: ["id", "ts"], type: "object", }, - selection: { fields: { mode: "explicit" } }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -136,7 +138,8 @@ test("registerConnector accepts connector_key plus manifest_uri manifests", asyn required: ["id", "updated_at"], type: "object", }, - selection: { fields: { mode: "explicit" } }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", diff --git a/reference-implementation/test/hosted-mcp-selection.test.ts b/reference-implementation/test/hosted-mcp-selection.test.ts index ac68ac934..3549d4295 100644 --- a/reference-implementation/test/hosted-mcp-selection.test.ts +++ b/reference-implementation/test/hosted-mcp-selection.test.ts @@ -275,25 +275,25 @@ test("hostedMcpSourceKey matches the dedupe key parseHostedMcpStreamSelections u // ─── Connection-pin enforcement lever (selection → grant scope) ───────────── // // The hosted MCP picker validates the owner's chosen connection, but the -// issued child grant only enforces it when `connection_id` lands on -// `grant.streams[]`. These tests pin the two pure deciders for that lever: +// issued child grant only enforces it when the opaque handle lands in +// `grant.streams[].instance_ids`. These tests pin the two pure deciders: // - `buildHostedMcpAuthorizationDetailForConnector` stamps (or omits) -// `streams[].connection_id`, including on the wildcard entry; +// `streams[].instance_ids`, including on the wildcard entry; // - `shouldPinSelectedConnection` pins ONLY when a specific connection was // chosen among more than one active binding, so single-connection grants // stay fan-in (no brittle stored id, no reissuance pressure). // The end-to-end persisted-grant + read-path proof lives in // `hosted-mcp-oauth.test.js`; these are the unit-level guards. -test("buildHostedMcpAuthorizationDetailForConnector omits connection_id when none is selected", () => { +test("buildHostedMcpAuthorizationDetailForConnector omits instance_ids when none is selected", () => { const detail = buildHostedMcpAuthorizationDetailForConnector("gmail", ["messages"], "continuous", null); assert.deepEqual(detail.streams, [{ name: "messages" }]); for (const stream of detail.streams) { - assert.equal("connection_id" in stream, false, "unpinned stream entry must not carry connection_id"); + assert.equal("instance_ids" in stream, false, "unpinned stream entry must not carry instance_ids"); } }); -test("buildHostedMcpAuthorizationDetailForConnector pins connection_id onto every narrowed stream entry", () => { +test("buildHostedMcpAuthorizationDetailForConnector pins instance_ids onto every narrowed stream entry", () => { const detail = buildHostedMcpAuthorizationDetailForConnector( "gmail", ["messages", "threads"], @@ -301,8 +301,8 @@ test("buildHostedMcpAuthorizationDetailForConnector pins connection_id onto ever "cin_work" ); assert.deepEqual(detail.streams, [ - { connection_id: "cin_work", name: "messages" }, - { connection_id: "cin_work", name: "threads" }, + { instance_ids: ["cin_work"], name: "messages" }, + { instance_ids: ["cin_work"], name: "threads" }, ]); }); @@ -310,7 +310,7 @@ test("buildHostedMcpAuthorizationDetailForConnector pins the wildcard stream ent // A wildcard pinned to a connection is intentionally valid: the runtime // narrows the binding to the connection, then expands streams under it. const detail = buildHostedMcpAuthorizationDetailForConnector("gmail", null, "continuous", "cin_personal"); - assert.deepEqual(detail.streams, [{ connection_id: "cin_personal", name: "*" }]); + assert.deepEqual(detail.streams, [{ instance_ids: ["cin_personal"], name: "*" }]); }); test("buildHostedMcpAuthorizationDetailForConnector treats blank/whitespace connectionId as unpinned", () => { diff --git a/reference-implementation/test/hosted-ui.test.ts b/reference-implementation/test/hosted-ui.test.ts index d75332a1d..8f41177bf 100644 --- a/reference-implementation/test/hosted-ui.test.ts +++ b/reference-implementation/test/hosted-ui.test.ts @@ -20,14 +20,18 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { initiateOwnerDeviceAuthorization } from "../server/auth.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { HOSTED_UI_CSS_PATH } from "../server/hosted-ui.ts"; import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); const SPOTIFY_MANIFEST = JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8")); const TEST_PASSWORD = "hosted-ui-test-password"; +const OWNER_SUBJECT_ID = "owner_local"; +const NOW = "2026-05-31T00:00:00.000Z"; interface CloseableTestServer { readonly asPort: number; @@ -88,6 +92,7 @@ async function startPendingConsent(asUrl: string): Promise<string> { if (!registerResp.ok) { throw new Error(`connector registration failed: ${registerResp.status}`); } + await seedSpotifyInstance(); const resp = await fetch(`${asUrl}/oauth/par`, { body: JSON.stringify({ authorization_details: [ @@ -110,6 +115,23 @@ async function startPendingConsent(asUrl: string): Promise<string> { return body.request_uri; } +async function seedSpotifyInstance(): Promise<void> { + const connectorId = canonicalConnectorKey(SPOTIFY_MANIFEST.connector_id); + assert.ok(connectorId, "spotify manifest must resolve to a canonical connector key"); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: "cin_hosted_ui_spotify", + createdAt: NOW, + displayName: "Hosted UI Spotify", + ownerSubjectId: OWNER_SUBJECT_ID, + sourceBinding: { account_hint: "hosted-ui@example.com" }, + sourceBindingKey: "hosted-ui@example.com", + sourceKind: "account", + status: "active", + updatedAt: NOW, + }); +} + /** * Assertions shared by every hosted page. Proves the page went through * `renderHostedDocument` rather than a route-local inline `<style>` block. @@ -151,7 +173,7 @@ test("hosted-ui: /consent uses the shared hosted-UI layer", async () => { assert.match(html, /Longview/, "shows client name"); assert.match(html, /concert-recommendation profile/, "shows purpose"); assert.match(html, /data-surface="human"/, "frames consent as a human surface"); - assert.match(html, /action="\/consent\/approve"/, "keeps allow action"); + assert.match(html, /action="\/consent\/review"/, "keeps review-first allow action"); assert.match(html, /action="\/consent\/deny"/, "keeps deny action"); }); }); @@ -186,8 +208,20 @@ test("hosted-ui: /device approval page uses the shared hosted-UI layer", async ( test("hosted-ui: /consent/approve result page uses the shared hosted-UI layer", async () => { await withServer({}, async ({ asUrl }) => { const requestUri = await startPendingConsent(asUrl); + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: "owner_local" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(reviewResp.status, 200); + const review = (await reviewResp.json()) as { approval_review_revision?: unknown }; + assert.equal(typeof review.approval_review_revision, "string", "consent review returns a revision"); + const reviewRevision = review.approval_review_revision as string; const resp = await fetch(`${asUrl}/consent/approve`, { - body: new URLSearchParams({ request_uri: requestUri, subject_id: "owner_local" }).toString(), + body: new URLSearchParams({ + approval_review_revision: reviewRevision, + request_uri: requestUri, + }).toString(), headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", }); diff --git a/reference-implementation/test/hybrid-retrieval.test.ts b/reference-implementation/test/hybrid-retrieval.test.ts index 9f29f44f3..c9b7bbb91 100644 --- a/reference-implementation/test/hybrid-retrieval.test.ts +++ b/reference-implementation/test/hybrid-retrieval.test.ts @@ -23,6 +23,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { startServer } from "../server/index.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; const TEST_DCR_INITIAL_ACCESS_TOKEN = "pdpp-reference-test-initial-access-token"; @@ -70,7 +71,24 @@ async function closeServer(server: StartedServer): Promise<void> { // overlapping candidates, and `comments` differs between the two (declares // both extensions but with different field sets) so source-specific hits // exercise lexical-only and semantic-only provenance paths. -const MANIFEST_A = { +function withCoreSourceDeclaration< + T extends { connector_id: string; display_name: string; protocol_version: string; streams: unknown[] }, +>(manifest: T) { + return { + ...manifest, + manifest_uri: `https://implementations.example/connectors/${manifest.connector_id}`, + source_declaration: { + declaration_version: `${manifest.connector_id}-declaration-v1`, + display: { name: manifest.display_name }, + protocol_version: manifest.protocol_version, + publisher: { id: "https://pdpp.dev/reference-implementation/tests" }, + source: { id: `https://registry.pdpp.dev/connectors/${manifest.connector_id}`, kind: "connector" }, + streams: manifest.streams, + }, + }; +} + +const MANIFEST_A = withCoreSourceDeclaration({ capabilities: { human_interaction: ["credentials"] }, connector_id: "hybrid-a", display_name: "Hybrid A", @@ -126,7 +144,7 @@ const MANIFEST_A = { }, ], version: "1.0.0", -}; +}); interface DeviceAuthorizationBody { device_code: string; @@ -165,9 +183,9 @@ async function issueOwnerToken(asUrl: string, subjectId = "owner_local"): Promis interface ApproveClientGrantParams { access_mode: string; client_id: string; - connector_id: string; purpose_code: string; purpose_description: string; + source_id: string; streams: { fields: string[]; name: string }[]; subject_id?: string; } @@ -181,14 +199,14 @@ interface ApprovedGrant { } async function approveClientGrant(asUrl: string, params: ApproveClientGrantParams): Promise<ApprovedGrant> { - const { body: initiateBody } = await fetchJson(`${asUrl}/oauth/par`, { + const { body: initiateBody, status: initiateStatus } = await fetchJson(`${asUrl}/oauth/par`, { body: JSON.stringify({ authorization_details: [ { access_mode: params.access_mode, purpose_code: params.purpose_code, purpose_description: params.purpose_description, - source: { id: params.connector_id, kind: "connector" }, + source: { id: params.source_id, kind: "connector" }, streams: params.streams, type: "https://pdpp.dev/data-access", }, @@ -198,13 +216,25 @@ async function approveClientGrant(asUrl: string, params: ApproveClientGrantParam headers: { "Content-Type": "application/json" }, method: "POST", }); + assert.equal(initiateStatus, 201, JSON.stringify(initiateBody)); const initiate = initiateBody as ParInitiateBody; - const { body: approved } = await fetchJson(`${asUrl}/consent/approve`, { + const review = await fetchJson(`${asUrl}/consent/review`, { body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: params.subject_id || "owner_local" }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); - return approved as ApprovedGrant; + assert.equal(review.status, 200, JSON.stringify(review.body)); + const reviewRevision = (review.body as Record<string, unknown>).approval_review_revision; + assert.equal(typeof reviewRevision, "string", "consent review must return approval_review_revision"); + const { body: approved, status: approvalStatus } = await fetchJson(`${asUrl}/consent/approve`, { + body: JSON.stringify({ approval_review_revision: reviewRevision, request_uri: initiate.request_uri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(approvalStatus, 200, JSON.stringify(approved)); + const result = approved as ApprovedGrant; + assert.ok(result.token, "consent approval token"); + return result; } interface IngestRecord { @@ -276,6 +306,12 @@ async function withHarness( method: "POST", }); assert.equal(reg.status, 201, `register ${manifest.connector_id}`); + await createRequestConnectorInstanceStore().ensureDefaultAccountConnection({ + connectorId: String(manifest.connector_id), + displayName: `${String(manifest.display_name)} test account`, + now: new Date().toISOString(), + ownerSubjectId: "owner_local", + }); } await fn({ asUrl, rsUrl, server }); } finally { @@ -504,9 +540,9 @@ test("client-token hybrid search respects the same grant projection as lexical + const approved = await approveClientGrant(asUrl, { access_mode: "continuous", client_id: "longview", - connector_id: connectorA, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "hybrid test", + source_id: MANIFEST_A.source_declaration.source.id, streams: [{ fields: ["id", "title"], name: "posts" }], }); @@ -537,9 +573,9 @@ test("client-token hybrid search rejects streams[] not in grant (same as lexical const approved = await approveClientGrant(asUrl, { access_mode: "continuous", client_id: "longview", - connector_id: connectorA, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "grant enforcement", + source_id: MANIFEST_A.source_declaration.source.id, streams: [{ fields: ["id", "title"], name: "posts" }], }); const { status, body } = await fetchJson(`${rsUrl}/v1/search/hybrid?q=overdraft&streams=comments`, { diff --git a/reference-implementation/test/introspection-http.test.ts b/reference-implementation/test/introspection-http.test.ts new file mode 100644 index 000000000..b139a3480 --- /dev/null +++ b/reference-implementation/test/introspection-http.test.ts @@ -0,0 +1,209 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + authenticateIntrospectionCaller, + basicIntrospectionAuthorization, + createRemoteIntrospector, +} from "../server/introspection-http.ts"; + +const CREDENTIALS = { clientId: "pr89-rs-test", clientSecret: "pr89-rs-test-secret" }; +const CLOCK_MS = Date.parse("2026-08-11T12:00:00Z"); +const ISSUER = "https://as.example"; +const AUDIENCE = "https://rs.example"; + +function validResponse(): Record<string, unknown> { + return { + active: true, + aud: AUDIENCE, + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personal-ai", + source: { id: "https://sources.example/spotify", kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: ["account-a"], name: "top_artists" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: "pr89-seam-client", + exp: CLOCK_MS / 1000 + 300, + grant_id: "grt_pr89", + grant_storage_binding: { connector_id: "spotify" }, + iss: ISSUER, + pdpp: { + client_id: "pr89-seam-client", + context_kind: "oauth_rar_0_1", + grant_id: "grt_pr89", + issued_at: "2026-08-11T11:55:00Z", + source: { id: "https://sources.example/spotify", kind: "connector" }, + source_declaration: { version: "spotify-v1" }, + subject_id: "owner_local", + }, + pdpp_token_kind: "client", + subject_id: "owner_local", + }; +} + +function responseFetch( + payload: Record<string, unknown>, + observe?: (request: { input: string | URL | Request; init?: RequestInit }) => void +): typeof fetch { + return ((input: string | URL | Request, init?: RequestInit) => { + observe?.({ input, ...(init ? { init } : {}) }); + return Promise.resolve( + new Response(JSON.stringify(payload), { headers: { "Content-Type": "application/json" }, status: 200 }) + ); + }) as typeof fetch; +} + +function introspectorFor(payload: Record<string, unknown>, fetchImpl = responseFetch(payload)) { + return createRemoteIntrospector({ + ...CREDENTIALS, + endpoint: `${ISSUER}/introspect`, + expectedAudience: () => AUDIENCE, + expectedIssuer: () => ISSUER, + fetchImpl, + now: () => CLOCK_MS, + }); +} + +test("confidential introspection caller authentication rejects missing and wrong credentials", () => { + assert.equal(authenticateIntrospectionCaller(undefined, CREDENTIALS), false); + assert.equal(authenticateIntrospectionCaller("Bearer token", CREDENTIALS), false); + assert.equal( + authenticateIntrospectionCaller( + basicIntrospectionAuthorization({ clientId: CREDENTIALS.clientId, clientSecret: "wrong" }), + CREDENTIALS + ), + false + ); + assert.equal(authenticateIntrospectionCaller(basicIntrospectionAuthorization(CREDENTIALS), CREDENTIALS), true); +}); + +test("remote introspection makes one authenticated HTTP request and resolves the response only", async () => { + const requests: Array<{ input: string | URL | Request; init?: RequestInit }> = []; + const introspect = introspectorFor( + validResponse(), + responseFetch(validResponse(), (request) => requests.push(request)) + ); + const result = await introspect("tok_pr89"); + + assert.equal(result.active, true, JSON.stringify(result)); + assert.equal(requests.length, 1); + assert.equal(String(requests[0]?.input), `${ISSUER}/introspect`); + assert.equal(requests[0]?.init?.method, "POST"); + assert.equal( + (requests[0]?.init?.headers as Record<string, string> | undefined)?.Authorization, + basicIntrospectionAuthorization(CREDENTIALS) + ); + assert.equal(requests[0]?.init?.body, "token=tok_pr89"); +}); + +test("remote introspection accepts an owner token with a subject binding", async () => { + const result = await introspectorFor({ + active: true, + aud: AUDIENCE, + iss: ISSUER, + pdpp_token_kind: "owner", + subject_id: "owner_local", + })("tok_owner"); + + assert.equal(result.active, true); + assert.equal(result.pdpp_token_kind, "owner"); + assert.equal(result.subject_id, "owner_local"); +}); + +test("remote introspection maps invalid authenticated responses to stable context reasons", async () => { + const cases: ReadonlyArray<{ + expected: string; + mutate: (response: Record<string, unknown>) => void; + }> = [ + { + expected: "context.issuer_mismatch", + mutate: (response) => { + response.iss = "https://wrong.example"; + }, + }, + { + expected: "context.audience_mismatch", + mutate: (response) => { + response.aud = "https://wrong.example"; + }, + }, + { + expected: "context.expired", + mutate: (response) => { + response.exp = CLOCK_MS / 1000; + }, + }, + { + expected: "context.cache_stale", + mutate: (response) => { + response.cache_expires_at = CLOCK_MS / 1000; + }, + }, + { + expected: "context.kind_mismatch", + mutate: (response) => { + response.pdpp_token_kind = "unsupported"; + }, + }, + { + expected: "context.identity_mismatch", + mutate: (response) => { + (response.pdpp as Record<string, unknown>).client_id = "wrong-client"; + }, + }, + { + expected: "context.source_mismatch", + mutate: (response) => { + (response.pdpp as Record<string, unknown>).source = { + id: "https://sources.example/wrong", + kind: "connector", + }; + }, + }, + { + expected: "context.grant_mismatch", + mutate: (response) => { + response.grant_id = "wrong-grant"; + }, + }, + { + expected: "context.rights_missing", + mutate: (response) => { + response.authorization_details = undefined; + }, + }, + { + expected: "context.rights_duplicated", + mutate: (response) => { + (response.pdpp as Record<string, unknown>).streams = []; + }, + }, + ]; + + for (const fixture of cases) { + const response = validResponse(); + fixture.mutate(response); + // biome-ignore lint/performance/noAwaitInLoops: Table rows are intentionally resolved in stable assertion order. + const result = await introspectorFor(response)("tok_pr89"); + assert.deepEqual(result, { active: false, inactive_reason: fixture.expected }); + } +}); + +test("remote introspection fails closed on transport, status, and JSON errors", async () => { + const failures: (typeof fetch)[] = [ + (() => Promise.reject(new Error("offline"))) as typeof fetch, + (() => Promise.resolve(new Response("denied", { status: 401 }))) as typeof fetch, + (() => Promise.resolve(new Response("not-json", { status: 200 }))) as typeof fetch, + ]; + for (const fetchImpl of failures) { + // biome-ignore lint/performance/noAwaitInLoops: Table rows are intentionally resolved in stable assertion order. + const result = await introspectorFor(validResponse(), fetchImpl)("tok_pr89"); + assert.deepEqual(result, { active: false, inactive_reason: "context.authentication_failed" }); + } +}); diff --git a/reference-implementation/test/introspection-manifest-fail-closed.test.ts b/reference-implementation/test/introspection-manifest-fail-closed.test.ts index 0cc3e29f1..73731d6fd 100644 --- a/reference-implementation/test/introspection-manifest-fail-closed.test.ts +++ b/reference-implementation/test/introspection-manifest-fail-closed.test.ts @@ -2,26 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Introspection MUST fail closed on an unexpected manifest-store / storage fault. - * - * Regression guard for a fail-open defect: `introspect()`'s client-token branch - * validated the persisted grant against its manifest inside a try/catch that - * returned inactive ONLY for a `grant_invalid`-coded error and SILENTLY SWALLOWED - * every other error, then fell through to mark the token `active: true`. An - * infrastructure fault (manifest-store outage, DB error) therefore resolved into - * an authorization "active" decision. - * - * The fix: a genuine `grant_invalid` still projects inactive; any other error - * propagates, so introspection can never convert an outage into `active: true`. - * - * Behavior deliberately PRESERVED (not changed by the fix): - * - A grant bound to an UNREGISTERED connector (manifest resolves to null) keeps - * the token active; the read path resolves the connector connector-first and - * returns a precise not_found there. That is asserted by pdpp.test.js - * "polyfill client reads fail connector-first ...". - * - * SQLite path runs everywhere; Postgres path runs only when PDPP_TEST_POSTGRES_URL - * is set. + * Introspection uses the closed resolved grant as authorization authority. + * Removing the mutable manifest catalog after issuance must not deactivate or + * reinterpret a valid grant. SQLite runs everywhere. Postgres runs only when + * PDPP_TEST_POSTGRES_URL is set. */ import assert from "node:assert/strict"; @@ -33,11 +17,32 @@ import { closePostgresStorage, initPostgresStorage, postgresQuery } from "../ser const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; const CONNECTOR_ID = "introspection_fail_closed"; +const DECLARATION_VERSION = "introspection-fail-closed-declaration-v1"; +const INSTANCE_ID = "cin_introspection_fail_closed"; +const SOURCE_ID = "https://registry.pdpp.dev/connectors/introspection-fail-closed"; const SUBJECT_ID = "introspection_subject"; const CLIENT_ID = "introspection_client"; const MANIFEST = { connector_id: CONNECTOR_ID, + display_name: "Introspection fail closed fixture", + manifest_uri: "https://implementations.example/connectors/introspection-fail-closed", + protocol_version: "0.1.0", + source_declaration: { + declaration_version: DECLARATION_VERSION, + display: { name: "Introspection fail closed fixture" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation/tests" }, + source: { id: SOURCE_ID, kind: "connector" }, + streams: [ + { + name: "items", + primary_key: ["id"], + schema: { properties: { id: { type: "string" } }, type: "object" }, + selection: { fields: true, resources: true }, + }, + ], + }, streams: [ { name: "items", @@ -54,13 +59,15 @@ type Backend = "sqlite" | "postgres"; function persistedGrant(grantId: string): string { return JSON.stringify({ access_mode: "continuous", - client_id: CLIENT_ID, + client: { client_id: CLIENT_ID }, grant_id: grantId, - manifest_version: MANIFEST.version, + issued_at: new Date().toISOString(), purpose_code: "https://pdpp.dev/purpose/analytics", - source: { id: CONNECTOR_ID, kind: "connector" }, - streams: [{ name: "items" }], - subject_id: SUBJECT_ID, + source: { id: SOURCE_ID, kind: "connector" }, + source_declaration: { version: DECLARATION_VERSION }, + streams: [{ fields: ["id"], instance_ids: [INSTANCE_ID], name: "items" }], + subject: { id: SUBJECT_ID }, + version: "0.1.0", }); } @@ -99,8 +106,7 @@ async function seedGrantToken(backend: Backend, grantId: string, tokenId: string .run(tokenId, grantId, SUBJECT_ID, CLIENT_ID); } -async function breakManifestStorage(backend: Backend): Promise<void> { - // Simulate a manifest-store outage: the table introspection reads is gone. +async function removeLiveManifestCatalog(backend: Backend): Promise<void> { if (backend === "postgres") { await postgresQuery("ALTER TABLE connectors RENAME TO connectors_unavailable"); return; @@ -108,7 +114,7 @@ async function breakManifestStorage(backend: Backend): Promise<void> { getDb().exec("DROP TABLE connectors"); } -async function runFailClosedCases(t: TestContext, backend: Backend): Promise<void> { +async function runSnapshotAuthorityCases(t: TestContext, backend: Backend): Promise<void> { initDb(":memory:"); if (backend === "postgres") { assert.ok(POSTGRES_URL, "Postgres URL is configured for the Postgres case"); @@ -125,18 +131,12 @@ async function runFailClosedCases(t: TestContext, backend: Backend): Promise<voi assert.equal(result.active, true); }); - await t.test("an unexpected manifest-storage fault propagates and never marks active", async () => { - await breakManifestStorage(backend); - // The security property: introspection MUST NOT resolve an infrastructure - // outage into `active: true`. It fails closed by propagating. - await assert.rejects( - introspect(tokenId), - (error: unknown) => { - assert.ok(error instanceof Error, "rejection must be an Error"); - return (error as Error & { code?: string }).code !== "grant_invalid"; - }, - "infra fault must propagate, not project the token active or as a clean grant_invalid" - ); + await t.test("manifest catalog removal does not replace the resolved grant authority", async () => { + await removeLiveManifestCatalog(backend); + const result = await introspect(tokenId); + assert.equal(result.active, true); + const grant = result.grant as { source_declaration?: { version?: string } } | undefined; + assert.equal(grant?.source_declaration?.version, DECLARATION_VERSION); }); } finally { if (backend === "postgres") { @@ -146,12 +146,12 @@ async function runFailClosedCases(t: TestContext, backend: Backend): Promise<voi } } -test("SQLite introspection fails closed on an unexpected manifest-storage fault", async (t) => { - await runFailClosedCases(t, "sqlite"); +test("SQLite introspection keeps the issued declaration snapshot authoritative", async (t) => { + await runSnapshotAuthorityCases(t, "sqlite"); }); -test("Postgres introspection fails closed on an unexpected manifest-storage fault", { +test("Postgres introspection keeps the issued declaration snapshot authoritative", { skip: !POSTGRES_URL, }, async (t) => { - await runFailClosedCases(t, "postgres"); + await runSnapshotAuthorityCases(t, "postgres"); }); diff --git a/reference-implementation/test/introspection-runtime-credentials.test.ts b/reference-implementation/test/introspection-runtime-credentials.test.ts new file mode 100644 index 000000000..90bc11cfb --- /dev/null +++ b/reference-implementation/test/introspection-runtime-credentials.test.ts @@ -0,0 +1,90 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { startServer } from "../server/index.ts"; +import { basicIntrospectionAuthorization, type IntrospectionCallerCredentials } from "../server/introspection-http.ts"; +import { TEST_RS_INTROSPECTION_CREDENTIALS } from "./helpers/introspection-test-credentials.ts"; + +type TestServer = Awaited<ReturnType<typeof startServer>> & { + asServer: { close: (callback: () => void) => void; closeAllConnections?: () => void }; + rsServer: { close: (callback: () => void) => void; closeAllConnections?: () => void }; +}; + +async function closeServer(server: TestServer): Promise<void> { + server.asServer.closeAllConnections?.(); + server.rsServer.closeAllConnections?.(); + await Promise.allSettled([ + new Promise<void>((resolve) => server.asServer.close(() => resolve())), + new Promise<void>((resolve) => server.rsServer.close(() => resolve())), + ]); +} + +function introspect(asPort: number, credentials: IntrospectionCallerCredentials): Promise<Response> { + return fetch(`http://localhost:${asPort}/introspect`, { + body: new URLSearchParams({ token: "tok_missing" }).toString(), + headers: { + Authorization: basicIntrospectionAuthorization(credentials), + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }); +} + +async function withoutNodeTestContext<T>(fn: () => Promise<T>): Promise<T> { + const previous = process.env.NODE_TEST_CONTEXT; + delete process.env.NODE_TEST_CONTEXT; + try { + return await fn(); + } finally { + if (previous === undefined) { + delete process.env.NODE_TEST_CONTEXT; + } else { + process.env.NODE_TEST_CONTEXT = previous; + } + } +} + +test("production default startup does not accept the repository test introspection credential", async () => { + await withoutNodeTestContext(async () => { + const server = (await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 })) as TestServer; + try { + const response = await introspect(server.asPort, TEST_RS_INTROSPECTION_CREDENTIALS); + assert.equal(response.status, 401); + } finally { + await closeServer(server); + } + }); +}); + +test("test-run startup also generates credentials unless the test injects them", async () => { + const server = (await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 })) as TestServer; + try { + const response = await introspect(server.asPort, TEST_RS_INTROSPECTION_CREDENTIALS); + assert.equal(response.status, 401); + } finally { + await closeServer(server); + } +}); +test("operator-provided introspection credentials are accepted", async () => { + await withoutNodeTestContext(async () => { + const credentials = { clientId: "operator-rs", clientSecret: "operator-rs-secret" }; + const server = (await startServer({ + asPort: 0, + dbPath: ":memory:", + introspectionCallerCredentials: credentials, + quiet: true, + rsIntrospectionCredentials: credentials, + rsPort: 0, + })) as TestServer; + try { + const response = await introspect(server.asPort, credentials); + assert.equal(response.status, 200); + const body = (await response.json()) as Record<string, unknown>; + assert.equal(body.active, false); + } finally { + await closeServer(server); + } + }); +}); diff --git a/reference-implementation/test/legacy-local-connector-manifest-alias.test.ts b/reference-implementation/test/legacy-local-connector-manifest-alias.test.ts index 621d633cd..f6e1a6d75 100644 --- a/reference-implementation/test/legacy-local-connector-manifest-alias.test.ts +++ b/reference-implementation/test/legacy-local-connector-manifest-alias.test.ts @@ -29,6 +29,7 @@ function validManifest(connectorId: string) { return { connector_id: connectorId, display_name: "Claude Code", + manifest_uri: "https://registry.pdpp.org/connectors/claude-code", protocol_version: "0.1.0", streams: [ { @@ -46,7 +47,7 @@ function validManifest(connectorId: string) { type: "object", }, selection: { fields: true, resources: true }, - semantics: "event_log", + semantics: "append_only", }, ], version: "0.3.0", diff --git a/reference-implementation/test/lexical-retrieval.test.ts b/reference-implementation/test/lexical-retrieval.test.ts index 3aa828b7a..19a80ac31 100644 --- a/reference-implementation/test/lexical-retrieval.test.ts +++ b/reference-implementation/test/lexical-retrieval.test.ts @@ -42,6 +42,7 @@ import test from "node:test"; import { closeDb, getDb, initDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; import { closePostgresStorage } from "../server/postgres-storage.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; import { buildSearchPlanForGrant, parseSearchParams } from "../server/search.ts"; // ─── harness ──────────────────────────────────────────────────────────────── @@ -206,14 +207,32 @@ interface ParInitiateResponse { } interface ApprovedGrantResponse { + grant?: { streams?: Array<{ fields?: string[]; name?: string }> }; token: string; } +function withCoreSourceDeclaration< + T extends { connector_id: string; display_name: string; protocol_version: string; streams: unknown[] }, +>(manifest: T) { + return { + ...manifest, + manifest_uri: `https://implementations.example/connectors/${manifest.connector_id}`, + source_declaration: { + declaration_version: `${manifest.connector_id}-declaration-v1`, + display: { name: manifest.display_name }, + protocol_version: manifest.protocol_version, + publisher: { id: "https://pdpp.dev/reference-implementation/tests" }, + source: { id: `https://registry.pdpp.dev/connectors/${manifest.connector_id}`, kind: "connector" }, + streams: manifest.streams, + }, + }; +} + // Two manifests with declared lexical_fields, designed to exercise // cross-connector owner mode AND a stream name shared across both // connectors. These are inline so the tests don't depend on any seed // manifest beyond what they explicitly install. -const REDDITISH_MANIFEST_A = { +const REDDITISH_MANIFEST_A = withCoreSourceDeclaration({ capabilities: { human_interaction: ["credentials"] }, connector_id: "redditish-a", display_name: "Redditish A", @@ -240,7 +259,7 @@ const REDDITISH_MANIFEST_A = { subreddit: { type: "string" }, title: { type: "string" }, }, - required: ["id", "title"], + required: ["id"], type: "object", }, selection: { fields: true, resources: false }, @@ -284,9 +303,9 @@ const REDDITISH_MANIFEST_A = { }, ], version: "1.0.0", -}; +}); -const REDDITISH_MANIFEST_B = { +const REDDITISH_MANIFEST_B = withCoreSourceDeclaration({ capabilities: { human_interaction: ["credentials"] }, connector_id: "redditish-b", display_name: "Redditish B", @@ -320,7 +339,7 @@ const REDDITISH_MANIFEST_B = { }, ], version: "1.0.0", -}; +}); async function issueOwnerToken(asUrl: string, subjectId = "owner_local"): Promise<string> { const clientId = "cli_longview"; @@ -351,22 +370,22 @@ async function issueOwnerToken(asUrl: string, subjectId = "owner_local"): Promis interface ApproveClientGrantParams { access_mode: string; client_id: string; - connector_id: string; purpose_code: string; purpose_description: string; + source_id: string; streams: Array<{ fields?: string[]; name: string }>; subject_id?: string; } async function approveClientGrant(asUrl: string, params: ApproveClientGrantParams): Promise<ApprovedGrantResponse> { - const { body: initiate } = await fetchJson<ParInitiateResponse>(`${asUrl}/oauth/par`, { + const { body: initiate, status: initiateStatus } = await fetchJson<ParInitiateResponse>(`${asUrl}/oauth/par`, { body: JSON.stringify({ authorization_details: [ { access_mode: params.access_mode, purpose_code: params.purpose_code, purpose_description: params.purpose_description, - source: { id: params.connector_id, kind: "connector" }, + source: { id: params.source_id, kind: "connector" }, streams: params.streams, type: "https://pdpp.dev/data-access", }, @@ -376,13 +395,34 @@ async function approveClientGrant(asUrl: string, params: ApproveClientGrantParam headers: { "Content-Type": "application/json" }, method: "POST", }); + assert.equal(initiateStatus, 201, JSON.stringify(initiate)); assert.ok(initiate, "PAR initiate response body"); - const { body: approved } = await fetchJson<ApprovedGrantResponse>(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: params.subject_id || "owner_local" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); + const subjectId = params.subject_id || "owner_local"; + const { body: review, status: reviewStatus } = await fetchJson<{ approval_review_revision?: unknown }>( + `${asUrl}/consent/review`, + { + body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: subjectId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + } + ); + assert.equal(reviewStatus, 200, JSON.stringify(review)); + assert.ok(review, "consent review returns a body"); + assert.equal(typeof review.approval_review_revision, "string", "consent review returns a revision"); + const { body: approved, status: approvalStatus } = await fetchJson<ApprovedGrantResponse>( + `${asUrl}/consent/approve`, + { + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: initiate.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + } + ); + assert.equal(approvalStatus, 200, JSON.stringify(approved)); assert.ok(approved, "consent approve response body"); + assert.ok(approved.token, "consent approval token"); return approved; } @@ -454,6 +494,12 @@ async function withHarness(opts: WithHarnessOpts, fn: (ctx: HarnessContext) => P method: "POST", }); assert.equal(reg.status, 201, `register ${manifest.connector_id}`); + await createRequestConnectorInstanceStore().ensureDefaultAccountConnection({ + connectorId: manifest.connector_id, + displayName: `${manifest.display_name} test account`, + now: new Date().toISOString(), + ownerSubjectId: "owner_local", + }); } await fn({ asUrl, rsUrl, server }); } finally { @@ -629,6 +675,7 @@ if (POSTGRES_URL) { capabilities: { human_interaction: ["credentials"] }, connector_id: connectorId, display_name: "Postgres Lexical Recall", + manifest_uri: `https://sources.example/connectors/${connectorId}`, protocol_version: "0.1.0", streams: [ { @@ -917,18 +964,18 @@ test("filtered lexical search rejects invalid filter shapes and still-forbidden const approved = await approveClientGrant(asUrl, { access_mode: "continuous", client_id: "longview", - connector_id: connectorA, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "lexical filtered retrieval test", + source_id: REDDITISH_MANIFEST_A.source_declaration.source.id, streams: [{ fields: ["id", "title", "source_created_at"], name: "posts" }], }); const unauthorized = await fetchJson<ErrorEnvelopeResponse>( `${rsUrl}/v1/search?q=invoice&streams=posts&filter[selftext]=secret`, { headers: { Authorization: `Bearer ${approved.token}` } } ); - assert.equal(unauthorized.status, 403); + assert.equal(unauthorized.status, 400); assert.ok(unauthorized.body, "unauthorized response body"); - assert.equal(unauthorized.body.error.code, "field_not_granted"); + assert.equal(unauthorized.body.error.code, "invalid_request"); }); }); @@ -984,9 +1031,9 @@ test("client-token streams[] not in grant returns grant_stream_not_allowed", asy const approved = await approveClientGrant(asUrl, { access_mode: "continuous", client_id: "longview", - connector_id: connectorA, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "lexical retrieval test", + source_id: REDDITISH_MANIFEST_A.source_declaration.source.id, streams: [{ fields: ["id", "title"], name: "posts" }], // posts only }); @@ -1000,14 +1047,14 @@ test("client-token streams[] not in grant returns grant_stream_not_allowed", asy }); }); -test("a persisted client grant for a removed stream closes every retrieval route before its adapter", async () => { +test("a current manifest without a granted stream returns typed not found on every retrieval route", async () => { await withHarness({}, async ({ asUrl, rsUrl }) => { const approved = await approveClientGrant(asUrl, { access_mode: "continuous", client_id: "longview", - connector_id: REDDITISH_MANIFEST_A.connector_id, purpose_code: "https://pdpp.dev/purpose/analytics", - purpose_description: "stale grant authority regression", + purpose_description: "current serving metadata regression", + source_id: REDDITISH_MANIFEST_A.source_declaration.source.id, streams: [{ fields: ["id", "title"], name: "posts" }], }); const currentManifest = { @@ -1030,9 +1077,9 @@ test("a persisted client grant for a removed stream closes every retrieval route const { status, body } = await fetchJson<ErrorEnvelopeResponse>(`${rsUrl}${path}`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(status, 403, path); + assert.equal(status, 404, `${path}: ${JSON.stringify(body)}`); assert.ok(body, `error response body for ${path}`); - assert.equal(body.error.code, "grant_invalid", path); + assert.equal(body.error.code, "stream_not_declared", path); } }); }); @@ -1110,9 +1157,9 @@ test("client grant authorizing only one of two declared lexical_fields restricts const approved = await approveClientGrant(asUrl, { access_mode: "continuous", client_id: "longview", - connector_id: connectorA, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "lexical retrieval test", + source_id: REDDITISH_MANIFEST_A.source_declaration.source.id, streams: [{ fields: ["id", "title"], name: "posts" }], }); @@ -1197,11 +1244,12 @@ test("grant with zero overlap on searchable fields contributes zero hits and no const approved = await approveClientGrant(asUrl, { access_mode: "continuous", client_id: "longview", - connector_id: connectorA, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "lexical retrieval test", + source_id: REDDITISH_MANIFEST_A.source_declaration.source.id, streams: [{ fields: ["id"], name: "posts" }], }); + assert.deepEqual(approved.grant?.streams?.[0]?.fields, ["id"], "issued grant keeps the approved field boundary"); const { status, body } = await fetchJson<SearchListResponse>(`${rsUrl}/v1/search?q=apricot`, { headers: { Authorization: `Bearer ${approved.token}` }, }); @@ -1574,15 +1622,15 @@ test("pre-existing records become searchable after lexical_fields are declared ( semantics: "append_only", ...overrides, }); - const manifestV1 = { + const manifestV1 = withCoreSourceDeclaration({ capabilities: { human_interaction: ["credentials"] }, connector_id: CONNECTOR_ID, display_name: "Late Bloomer", protocol_version: "0.1.0", streams: [baseStream()], version: "1.0.0", - }; - const manifestV2 = { + }); + const manifestV2 = withCoreSourceDeclaration({ ...manifestV1, streams: [ baseStream({ @@ -1590,7 +1638,7 @@ test("pre-existing records become searchable after lexical_fields are declared ( }), ], version: "2.0.0", - }; + }); try { // (1) Register without lexical_fields. @@ -1744,19 +1792,19 @@ test("manifest update that swaps lexical_fields (same cardinality) rebuilds the // v1: lexical_fields = ['title']. v2: lexical_fields = ['selftext']. // Same cardinality (1) — defeats the row-count heuristic on its own. - const manifestV1 = { + const manifestV1 = withCoreSourceDeclaration({ capabilities: { human_interaction: ["credentials"] }, connector_id: CONNECTOR_ID, display_name: "Field Swap", protocol_version: "0.1.0", streams: [baseStream({ query: { search: { lexical_fields: ["title"] } } })], version: "1.0.0", - }; - const manifestV2 = { + }); + const manifestV2 = withCoreSourceDeclaration({ ...manifestV1, streams: [baseStream({ query: { search: { lexical_fields: ["selftext"] } } })], version: "2.0.0", - }; + }); try { // Register v1 (title-searchable). diff --git a/reference-implementation/test/manifest-stream-availability.test.ts b/reference-implementation/test/manifest-stream-availability.test.ts index 9598a2551..6ae3408e7 100644 --- a/reference-implementation/test/manifest-stream-availability.test.ts +++ b/reference-implementation/test/manifest-stream-availability.test.ts @@ -98,6 +98,7 @@ function makeManifest({ availability }: { availability?: unknown } = {}) { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, }, ], version: "0.1.0", diff --git a/reference-implementation/test/mcp-event-subscription-e2e.test.ts b/reference-implementation/test/mcp-event-subscription-e2e.test.ts index feaa58e56..d44494332 100644 --- a/reference-implementation/test/mcp-event-subscription-e2e.test.ts +++ b/reference-implementation/test/mcp-event-subscription-e2e.test.ts @@ -17,9 +17,12 @@ import { fileURLToPath } from "node:url"; import { canonicalConnectorKeyFromManifest } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); +const OWNER_SUBJECT_ID = "owner_local"; +const NOW = "2026-05-31T00:00:00.000Z"; interface CloseableServer { close: (callback?: (err?: Error) => void) => unknown; @@ -73,18 +76,34 @@ interface Manifest { } async function registerSpotify(asUrl: string): Promise<Manifest> { - const raw = JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8")) as Manifest; - const canonical = canonicalConnectorKeyFromManifest(raw); - const manifest = canonical && canonical !== raw.connector_id ? { ...raw, connector_id: canonical } : raw; + const manifest = JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8")) as Manifest; const { status } = await fetchJson(`${asUrl}/connectors`, { body: JSON.stringify(manifest), headers: { "Content-Type": "application/json" }, method: "POST", }); assert.equal(status, 201); + await seedSpotifyInstance(manifest); return manifest; } +async function seedSpotifyInstance(manifest: Manifest): Promise<void> { + const connectorId = canonicalConnectorKeyFromManifest(manifest); + assert.ok(connectorId, "spotify manifest must resolve to a canonical connector key"); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: "cin_mcp_event_subscription_spotify", + createdAt: NOW, + displayName: "MCP Event Subscription Spotify", + ownerSubjectId: OWNER_SUBJECT_ID, + sourceBinding: { account_hint: "mcp-event-subscription@example.com" }, + sourceBindingKey: "mcp-event-subscription@example.com", + sourceKind: "account", + status: "active", + updatedAt: NOW, + }); +} + interface AuthCodeClient { client_id: string; [key: string]: unknown; @@ -144,9 +163,21 @@ async function completeOauthCodeFlow({ const requestUri = consentUrl.searchParams.get("request_uri"); assert.ok(requestUri); + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: "owner_local" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewBody = (await reviewResp.json()) as { approval_review?: unknown; approval_review_revision?: unknown }; + assert.equal(reviewResp.status, 200, JSON.stringify(reviewBody)); + assert.ok(reviewBody.approval_review && typeof reviewBody.approval_review === "object"); + assert.equal(typeof reviewBody.approval_review_revision, "string"); const approveResp = await fetch(`${asUrl}/consent/approve`, { - body: new URLSearchParams({ request_uri: requestUri, subject_id: "owner_local" }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: JSON.stringify({ + approval_review_revision: reviewBody.approval_review_revision, + request_uri: requestUri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", redirect: "manual", }); diff --git a/reference-implementation/test/metadata-resource-capability-projection.test.ts b/reference-implementation/test/metadata-resource-capability-projection.test.ts index 80225b07d..aa3656b68 100644 --- a/reference-implementation/test/metadata-resource-capability-projection.test.ts +++ b/reference-implementation/test/metadata-resource-capability-projection.test.ts @@ -31,6 +31,8 @@ import test from "node:test"; import { buildProtectedResourceMetadata, buildSemanticRetrievalCapability } from "../server/metadata.ts"; +const INVALID_SOURCE_DECLARATION_POINTER = /Invalid provider-native source declaration pointer/; + function baseMetadataInput(overrides = {}) { return { authorizationServers: ["https://as.example.com"], @@ -82,6 +84,32 @@ test("buildProtectedResourceMetadata: optional discovery/agent/onboarding blocks assert.equal(meta.pdpp_owner_agent_onboarding, onboarding, "owner-agent onboarding passed through"); }); +test("buildProtectedResourceMetadata: emits only a contract-valid provider-native declaration pointer", () => { + const sourceDeclarationUri = "https://declarations.example.com/source.json"; + const metadata = buildProtectedResourceMetadata(baseMetadataInput({ sourceDeclarationUri })); + assert.equal(metadata.pdpp_source_declaration_uri, sourceDeclarationUri); + + assert.throws( + () => + buildProtectedResourceMetadata( + baseMetadataInput({ sourceDeclarationUri: "https://user@declarations.example.com/source.json" }) + ), + INVALID_SOURCE_DECLARATION_POINTER + ); + assert.throws( + () => + buildProtectedResourceMetadata( + baseMetadataInput({ sourceDeclarationUri: "https://declarations.example.com/source.json#v1" }) + ), + INVALID_SOURCE_DECLARATION_POINTER + ); + assert.throws( + () => buildProtectedResourceMetadata(baseMetadataInput({ sourceDeclarationUri: "" })), + INVALID_SOURCE_DECLARATION_POINTER, + "an explicitly configured empty pointer must fail closed instead of being omitted" + ); +}); + test("buildProtectedResourceMetadata: capabilities emitted only when a NON-EMPTY object", () => { const withEmpty = buildProtectedResourceMetadata(baseMetadataInput({ capabilities: {} })); assert.equal("capabilities" in withEmpty, false, "empty capabilities object must be omitted"); diff --git a/reference-implementation/test/oauth-code-delivery-atomicity.test.ts b/reference-implementation/test/oauth-code-delivery-atomicity.test.ts new file mode 100644 index 000000000..c859c31a4 --- /dev/null +++ b/reference-implementation/test/oauth-code-delivery-atomicity.test.ts @@ -0,0 +1,132 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { createHash, randomBytes } from "node:crypto"; +import test from "node:test"; + +import { + issueOAuthAuthorizationCodeForDeviceCode, + issueOAuthAuthorizationCodeForPackageDeviceCode, + stageOAuthAuthorizationCodeRequest, +} from "../server/auth.ts"; +import { closeDb, getDb, initDb } from "../server/db.ts"; +import { closePostgresStorage, initPostgresStorage, postgresQuery } from "../server/postgres-storage.ts"; +import { dedicatedPostgresTestUrl } from "./helpers/dedicated-postgres-test-url.ts"; +import { withTemporaryPostgresDatabase } from "./helpers/postgres-temp-database.ts"; + +const POSTGRES_URL = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); +const NOT_RECOVERABLE = /not recoverable/; + +type Backend = "postgres" | "sqlite"; + +async function markCodeConsumed(backend: Backend, code: string): Promise<void> { + if (backend === "postgres") { + await postgresQuery( + "UPDATE oauth_authorization_codes SET status = 'consumed', consumed_at = NOW() WHERE code = $1", + [code] + ); + return; + } + getDb() + .prepare("UPDATE oauth_authorization_codes SET status = 'consumed', consumed_at = datetime('now') WHERE code = ?") + .run(code); +} + +async function countIssuedRows(backend: Backend, deviceCode: string): Promise<number> { + if (backend === "postgres") { + const result = await postgresQuery<{ count: number }>( + "SELECT COUNT(*)::int AS count FROM oauth_authorization_codes WHERE device_code = $1", + [deviceCode] + ); + return result.rows[0]?.count ?? 0; + } + return ( + getDb() + .prepare("SELECT COUNT(*) AS count FROM oauth_authorization_codes WHERE device_code = ?") + .get(deviceCode) as { + count: number; + } + ).count; +} + +async function exerciseDelivery(backend: Backend): Promise<void> { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const clientId = `client_${backend}_${randomBytes(6).toString("hex")}`; + const redirectUri = `https://${backend}.client.example/callback`; + const stage = async (deviceCode: string) => { + await stageOAuthAuthorizationCodeRequest({ + clientId, + codeChallenge: challenge, + codeChallengeMethod: "S256", + deviceCode, + redirectUri, + state: "delivery-state", + }); + }; + + const grantDeviceCode = `device_grant_${backend}_${randomBytes(6).toString("hex")}`; + await stage(grantDeviceCode); + const grantBinding = { grantId: `grt_${backend}`, token: `tok_${backend}` }; + const grantResults = await Promise.all([ + issueOAuthAuthorizationCodeForDeviceCode(grantDeviceCode, grantBinding), + issueOAuthAuthorizationCodeForDeviceCode(grantDeviceCode, grantBinding), + ]); + assert.deepEqual(grantResults[1], grantResults[0], "concurrent delivery must converge on the persisted code"); + assert.equal(grantResults[0]?.redirect_uri, redirectUri); + assert.equal(typeof grantResults[0]?.code, "string"); + assert.equal(await countIssuedRows(backend, grantDeviceCode), 1); + await assert.rejects( + () => issueOAuthAuthorizationCodeForDeviceCode(grantDeviceCode, { ...grantBinding, token: "tok_mismatch" }), + NOT_RECOVERABLE + ); + await markCodeConsumed(backend, String(grantResults[0]?.code)); + await assert.rejects(() => issueOAuthAuthorizationCodeForDeviceCode(grantDeviceCode, grantBinding), NOT_RECOVERABLE); + + const packageDeviceCode = `device_package_${backend}_${randomBytes(6).toString("hex")}`; + await stage(packageDeviceCode); + const packageBinding = { packageId: `gpkg_${backend}`, token: `tok_pkg_${backend}` }; + const packageResults = await Promise.all([ + issueOAuthAuthorizationCodeForPackageDeviceCode(packageDeviceCode, packageBinding), + issueOAuthAuthorizationCodeForPackageDeviceCode(packageDeviceCode, packageBinding), + ]); + assert.deepEqual(packageResults[1], packageResults[0], "package delivery retry must return the persisted code"); + assert.equal(packageResults[0]?.redirect_uri, redirectUri); + assert.equal(typeof packageResults[0]?.code, "string"); + assert.equal(await countIssuedRows(backend, packageDeviceCode), 1); + await assert.rejects( + () => + issueOAuthAuthorizationCodeForPackageDeviceCode(packageDeviceCode, { + ...packageBinding, + packageId: "gpkg_mismatch", + }), + NOT_RECOVERABLE + ); +} + +test("SQLite authorization-code delivery is CAS-bound and recoverable", async () => { + initDb(":memory:"); + try { + await exerciseDelivery("sqlite"); + } finally { + closeDb(); + } +}); + +test("PostgreSQL authorization-code delivery is CAS-bound and recoverable", { + skip: POSTGRES_URL ? false : "PDPP_TEST_POSTGRES_URL unset", +}, async () => { + assert.ok(POSTGRES_URL); + await withTemporaryPostgresDatabase( + { + closeConnections: closePostgresStorage, + connectionString: POSTGRES_URL, + databaseName: "pdpp_test_oauth_code_delivery", + }, + async (databaseUrl) => { + await initPostgresStorage({ backend: "postgres", databaseUrl }); + await exerciseDelivery("postgres"); + } + ); +}); diff --git a/reference-implementation/test/oauth-error-contract.test.ts b/reference-implementation/test/oauth-error-contract.test.ts index 00c972677..2e600831c 100644 --- a/reference-implementation/test/oauth-error-contract.test.ts +++ b/reference-implementation/test/oauth-error-contract.test.ts @@ -2,11 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import type { CimdTransportFailureEvent } from "../server/cimd.ts"; import { startServer } from "../server/index.ts"; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REFERENCE_IMPL_DIR = join(__dirname, ".."); + interface CloseableServer { close: (callback?: (err?: Error) => void) => unknown; closeAllConnections: () => void; @@ -32,6 +38,8 @@ interface OAuthErrorBody { request_id?: unknown; } +const SPOTIFY_SOURCE_ID = "https://registry.pdpp.dev/connectors/spotify"; + async function postForm( url: string, params: Record<string, string> @@ -133,12 +141,19 @@ test("MCP device authorization emits one CIMD transport event without changing i const asUrl = `http://localhost:${server.asPort}`; try { + const spotifyManifest = JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8")); + const registerResp = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(spotifyManifest), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(registerResp.status, 201); const { resp, body } = await postForm(`${asUrl}/oauth/device_authorization`, { authorization_details: JSON.stringify([ { access_mode: "single_use", purpose_code: "https://pdpp.dev/purpose/personal_assistant", - source: { id: "connector-test", kind: "connector" }, + source: { id: SPOTIFY_SOURCE_ID, kind: "connector" }, streams: [{ name: "*" }], type: "https://pdpp.dev/data-access", }, @@ -148,7 +163,7 @@ test("MCP device authorization emits one CIMD transport event without changing i }); assert.equal(resp.status, 400); - assert.equal(body.error, "cimd_fetch_failed"); + assert.equal(body.error, "cimd_fetch_failed", String(body.error_description)); assertOAuthErrorHasRequestId(resp, body); assert.equal(events.length, 1); assert.equal(events[0]?.event_type, "cimd.transport_failure"); diff --git a/reference-implementation/test/owner-access-reference-contracts.test.ts b/reference-implementation/test/owner-access-reference-contracts.test.ts index e4ce19795..aed10931c 100644 --- a/reference-implementation/test/owner-access-reference-contracts.test.ts +++ b/reference-implementation/test/owner-access-reference-contracts.test.ts @@ -6,6 +6,8 @@ import test from "node:test"; import { getDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; +import { introspectionHeaders } from "./helpers/introspection.ts"; +import { TEST_RS_INTROSPECTION_CREDENTIALS } from "./helpers/introspection-test-credentials.ts"; interface CloseableServer { close: (callback?: (err?: Error) => void) => unknown; @@ -18,7 +20,7 @@ type StartedServer = Awaited<ReturnType<typeof startServer>> & { }; // Integration coverage for the additive owner-access reference contracts -// (OpenSpec change redesign-owner-console-product-experience, tasks 10.C.1–4): +// (OpenSpec change redesign-owner-console-product-experience, tasks 10.C.1-4): // - PATCH /oauth/register/:clientId (client-name update) // - GET /_ref/clients/:clientId/tokens (per-client token listing) // - DELETE /_ref/clients/:clientId/tokens/:id (per-token revoke) @@ -60,9 +62,11 @@ async function withServer(fn: (ctx: { asUrl: string }) => Promise<void>): Promis asPort: 0, dbPath: ":memory:", dynamicClientRegistrationInitialAccessTokens: [TEST_DCR_INITIAL_ACCESS_TOKEN], + introspectionCallerCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, ownerAuthPassword: TEST_PASSWORD, ownerAuthSubjectId: TEST_SUBJECT, quiet: true, + rsIntrospectionCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, rsPort: 0, })) as StartedServer; try { @@ -209,7 +213,7 @@ async function issueOwnerTokenViaDeviceFlow( device_code: device.device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code", }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); assert.equal(tokenResp.status, 200); @@ -240,7 +244,7 @@ interface RevokeResult { async function introspect(asUrl: string, token: string): Promise<IntrospectResult> { const resp = await fetch(`${asUrl}/introspect`, { body: JSON.stringify({ token }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); assert.equal(resp.status, 200); @@ -500,11 +504,56 @@ test("10.C.4 GET /_ref/grant-packages/count returns the total without paging the const now = new Date().toISOString(); const db = getDb(); for (const pid of ["gpkg_count_a", "gpkg_count_b"]) { + const grantId = `${pid}_grant`; + db.prepare(` + INSERT INTO grants(grant_id, subject_id, client_id, storage_binding_json, grant_json, + access_mode, status, consumed, issued_at, expires_at, trace_id, scenario_id) + VALUES (?, ?, 'cli_x', ?, ?, 'continuous', 'active', 0, ?, NULL, 't', 's') + `).run( + grantId, + TEST_SUBJECT, + JSON.stringify({ connector_id: "spotify" }), + JSON.stringify({ + access_mode: "continuous", + client: { client_id: "cli_x" }, + grant_id: grantId, + issued_at: now, + source: { id: "https://registry.pdpp.dev/connectors/spotify", kind: "connector" }, + source_declaration: { version: "reference.source-declaration.test.v1" }, + streams: [], + subject: { id: TEST_SUBJECT }, + version: "0.1.0", + }), + now + ); db.prepare(` INSERT INTO grant_packages(package_id, subject_id, client_id, status, package_json, trace_id, scenario_id, created_at, approved_at, revoked_at) VALUES (?, ?, 'cli_x', 'active', ?, 't', 's', ?, ?, NULL) - `).run(pid, TEST_SUBJECT, JSON.stringify({ package_id: pid, version: "test" }), now, now); + `).run( + pid, + TEST_SUBJECT, + JSON.stringify({ + approved_source_count: 1, + client: { client_display: null, client_id: "cli_x", registration_mode: "dynamic" }, + package_id: pid, + source_bounded_child_grants: true, + subject: { id: TEST_SUBJECT }, + version: "reference.mcp_package.v2", + }), + now, + now + ); + db.prepare(` + INSERT INTO grant_package_members(package_id, grant_id, token_id, source_json, status, added_at, revoked_at) + VALUES (?, ?, ?, ?, 'active', ?, NULL) + `).run( + pid, + grantId, + `${pid}_token`, + JSON.stringify({ id: "https://registry.pdpp.dev/connectors/spotify", kind: "connector" }), + now + ); } const two = await fetch(`${asUrl}/_ref/grant-packages/count`, { headers: { Cookie: sessionCookie } }); diff --git a/reference-implementation/test/owner-auth.test.ts b/reference-implementation/test/owner-auth.test.ts index 5c6aa69df..d3dc3ad03 100644 --- a/reference-implementation/test/owner-auth.test.ts +++ b/reference-implementation/test/owner-auth.test.ts @@ -10,6 +10,7 @@ import { getOwnerDeviceAuthorizationByUserCode, initiateOwnerDeviceAuthorization import { canonicalConnectorKey } from "../server/connector-key.ts"; import { closeDb, getDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); @@ -21,7 +22,10 @@ const SPOTIFY_MANIFEST = JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, "manif const TEST_DCR_INITIAL_ACCESS_TOKEN = "pdpp-reference-test-initial-access-token"; const TEST_PASSWORD = "placeholder-test-password"; const CUSTOM_SUBJECT_ID = "owner_testing_custom"; +const OWNER_SUBJECT_ID = "owner_local"; +const NOW = "2026-05-31T00:00:00.000Z"; const CSRF_HIDDEN_FIELD_PATTERN = /<input type="hidden" name="_csrf" value="([^"]+)"\s*\/>/; +const CONSENT_REQUEST_PATTERN = /Consent request/; interface CloseableServer { close: (callback?: (err?: Error) => void) => unknown; @@ -108,7 +112,11 @@ async function withServer( } } -async function startPendingConsent(asUrl: string, overrides: Record<string, unknown> = {}): Promise<string> { +async function startPendingConsent( + asUrl: string, + overrides: Record<string, unknown> = {}, + instanceOwnerSubjectIds: readonly string[] = [OWNER_SUBJECT_ID, CUSTOM_SUBJECT_ID] +): Promise<string> { const registerResp = await fetch(`${asUrl}/connectors`, { body: JSON.stringify(SPOTIFY_MANIFEST), headers: { "Content-Type": "application/json" }, @@ -118,6 +126,7 @@ async function startPendingConsent(asUrl: string, overrides: Record<string, unkn const text = await registerResp.text(); throw new Error(`connector registration failed: ${registerResp.status} ${text}`); } + await seedSpotifyInstance(instanceOwnerSubjectIds); const resp = await fetch(`${asUrl}/oauth/par`, { body: JSON.stringify({ authorization_details: [ @@ -148,6 +157,54 @@ async function startPendingConsent(asUrl: string, overrides: Record<string, unkn return body.request_uri; } +async function reviewPendingConsent( + asUrl: string, + requestUri: string, + cookie: string, + subjectId?: string +): Promise<string> { + const resp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, ...(subjectId ? { subject_id: subjectId } : {}) }), + headers: { Accept: "application/json", "Content-Type": "application/json", Cookie: cookie }, + method: "POST", + }); + const text = await resp.text(); + assert.equal(resp.status, 200, text); + const body = JSON.parse(text) as { + approval_review?: object; + approval_review_revision?: string; + request_uri?: string; + }; + assert.ok(body.approval_review); + assert.ok(body.approval_review_revision); + assert.equal(body.request_uri, requestUri); + return body.approval_review_revision; +} + +async function seedSpotifyInstance( + ownerSubjectIds: readonly string[] = [OWNER_SUBJECT_ID, CUSTOM_SUBJECT_ID] +): Promise<void> { + const connectorId = canonicalConnectorKey(SPOTIFY_MANIFEST.connector_id); + assert.ok(connectorId, "spotify manifest must resolve to a canonical connector key"); + const store = createSqliteConnectorInstanceStore(); + await Promise.all( + ownerSubjectIds.map((ownerSubjectId) => + store.upsert({ + connectorId, + connectorInstanceId: `cin_owner_auth_spotify_${ownerSubjectId}`, + createdAt: NOW, + displayName: "Owner Auth Spotify", + ownerSubjectId, + sourceBinding: { account_hint: `${ownerSubjectId}@example.com` }, + sourceBindingKey: `${ownerSubjectId}@example.com`, + sourceKind: "account", + status: "active", + updatedAt: NOW, + }) + ) + ); +} + function getRawSetCookieList(resp: Response): string[] { // node:fetch's Headers.getSetCookie() returns the full per-cookie list // (each value as a separate string) instead of the joined comma-list @@ -315,6 +372,52 @@ test("owner-auth placeholder: when PDPP_OWNER_PASSWORD unset, /consent and /devi }); }); +test("owner-auth placeholder: open local-dev HTML display defers subject-bound resolution to JSON review", async () => { + const customSubjectId = "u1"; + await withServer({}, async ({ asUrl }) => { + const requestUri = await startPendingConsent(asUrl, {}, [customSubjectId]); + + const display = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(requestUri)}`, { + headers: { Accept: "text/html" }, + redirect: "manual", + }); + const displayHtml = await display.text(); + assert.equal(display.status, 200, displayHtml); + assert.match(displayHtml, CONSENT_REQUEST_PATTERN); + + const review = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: customSubjectId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewText = await review.text(); + assert.equal(review.status, 200, reviewText); + const reviewBody = JSON.parse(reviewText) as { + approval_review?: { subject?: { id?: string } }; + approval_review_revision?: string; + }; + assert.equal(reviewBody.approval_review?.subject?.id, customSubjectId, "review binds the submitted subject"); + assert.ok(reviewBody.approval_review_revision, "review materializes a revision"); + + const approved = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ approval_review_revision: reviewBody.approval_review_revision, request_uri: requestUri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const approvedText = await approved.text(); + assert.equal(approved.status, 200, approvedText); + const approvedBody = JSON.parse(approvedText) as { + grant?: { streams?: Array<{ instance_ids?: string[] }>; subject?: { id?: string } }; + }; + assert.equal( + approvedBody.grant?.subject?.id, + customSubjectId, + "revision-only approval preserves the reviewed subject" + ); + assert.deepEqual(approvedBody.grant?.streams?.[0]?.instance_ids, [`cin_owner_auth_spotify_${customSubjectId}`]); + }); +}); + // ── 2. enabled: unauthenticated HTML requests redirect to /owner/login ──────── test("owner-auth placeholder: enabled — unauthenticated /consent and /device redirect to /owner/login", async () => { await withServer({ ownerAuthPassword: TEST_PASSWORD }, async ({ asUrl }) => { @@ -436,9 +539,10 @@ test("owner-auth placeholder: authenticated /consent/approve issues a grant and await withServer({ ownerAuthPassword: TEST_PASSWORD }, async ({ asUrl }) => { const { cookie } = await login(asUrl, TEST_PASSWORD); const requestUri = await startPendingConsent(asUrl); + const approvalReviewRevision = await reviewPendingConsent(asUrl, requestUri, cookie || ""); const approveResp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: requestUri }), + body: JSON.stringify({ approval_review_revision: approvalReviewRevision, request_uri: requestUri }), headers: { Accept: "application/json", "Content-Type": "application/json", @@ -504,15 +608,29 @@ test("owner-auth placeholder: authenticated /consent/approve issues a grant and }); // ── 6. enabled: submitted subject_id is ignored, configured subject wins ───── -test("owner-auth placeholder: enabled — submitted subject_id is ignored on approve", async () => { +test("owner-auth placeholder: enabled — submitted subject_id is ignored during consent review", async () => { await withServer({ ownerAuthPassword: TEST_PASSWORD, ownerAuthSubjectId: CUSTOM_SUBJECT_ID }, async ({ asUrl }) => { const { cookie } = await login(asUrl, TEST_PASSWORD); - const requestUri = await startPendingConsent(asUrl); + const requestUri = await startPendingConsent(asUrl, {}, [CUSTOM_SUBJECT_ID]); + const display = await fetch( + `${asUrl}/consent?request_uri=${encodeURIComponent(requestUri)}&subject_id=attacker_injected_subject`, + { + headers: { Accept: "text/html", Cookie: cookie || "" }, + redirect: "manual", + } + ); + assert.equal(display.status, 200, await display.text()); + const approvalReviewRevision = await reviewPendingConsent( + asUrl, + requestUri, + cookie || "", + "attacker_injected_subject" + ); const resp = await fetch(`${asUrl}/consent/approve`, { body: JSON.stringify({ + approval_review_revision: approvalReviewRevision, request_uri: requestUri, - subject_id: "attacker_injected_subject", }), headers: { Accept: "application/json", diff --git a/reference-implementation/test/owner-connection-delete.test.ts b/reference-implementation/test/owner-connection-delete.test.ts index a65574e8e..72010e3d3 100644 --- a/reference-implementation/test/owner-connection-delete.test.ts +++ b/reference-implementation/test/owner-connection-delete.test.ts @@ -162,7 +162,12 @@ async function issueOwnerToken(asUrl: string, subjectId = OWNER_SUBJECT_ID): Pro return tok.access_token; } -async function approveClientGrant(asUrl: string, connectorId: string, streamName: string): Promise<string> { +async function approveClientGrant( + asUrl: string, + sourceId: string, + streamName: string, + instanceId: string +): Promise<string> { const par = ( await fetchJson(`${asUrl}/oauth/par`, { body: JSON.stringify({ @@ -171,8 +176,8 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "owner-connection delete boundary test", - source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id"], name: streamName }], + source: { id: sourceId, kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: [instanceId], name: streamName }], type: "https://pdpp.dev/data-access", }, ], @@ -182,10 +187,24 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName method: "POST", }) ).body as { request_uri?: string }; + assert.ok(par.request_uri); + const review = ( + await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }) + ).body as { approval_review?: object; approval_review_revision?: string; request_uri?: string }; + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, par.request_uri); const approved = ( await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }) ).body as { token?: string }; @@ -853,7 +872,7 @@ test("owner-agent delete leaves disclosure grants untouched (I10)", async () => displayName: "Grantable", sourceBindingKey: "g@example.com", }); - await approveClientGrant(asUrl, connectorKey, stream); + await approveClientGrant(asUrl, manifest.connector_id, stream, "cin_grantable"); // The PAR/consent flow records a row in `grants` (status + scope + members // live there); delete must not touch it. const grantsBefore = getDb().prepare("SELECT grant_id, status FROM grants WHERE status = 'active'").all() as { @@ -940,7 +959,7 @@ test("owner-agent delete rejects a client grant token with 403 and audits it", a // biome-ignore lint/style/useDestructuring: index access documents the asserted ordered position const firstStream = manifest.streams[0]; assert.ok(firstStream, "expected the manifest to declare at least one stream"); - const clientToken = await approveClientGrant(asUrl, connectorKey, firstStream.name); + const clientToken = await approveClientGrant(asUrl, manifest.connector_id, firstStream.name, "cin_cli"); const { status, body: rawBody, resp } = await deleteConnection(rsUrl, clientToken, "/v1/owner/connections/cin_cli"); const body = rawBody as DeleteResponseBody; diff --git a/reference-implementation/test/owner-connection-diagnostics.test.ts b/reference-implementation/test/owner-connection-diagnostics.test.ts index f7075a147..bbf0969fa 100644 --- a/reference-implementation/test/owner-connection-diagnostics.test.ts +++ b/reference-implementation/test/owner-connection-diagnostics.test.ts @@ -139,7 +139,12 @@ async function issueOwnerToken(asUrl: string, subjectId = OWNER_SUBJECT_ID): Pro // PAR + consent yields a grant-scoped client-kind bearer (pdpp_token_kind: // "client"). These must NOT reach the owner-agent control surface. -async function approveClientGrant(asUrl: string, connectorId: string, streamName: string): Promise<string> { +async function approveClientGrant( + asUrl: string, + sourceId: string, + streamName: string, + instanceId: string +): Promise<string> { const par = ( await fetchJson(`${asUrl}/oauth/par`, { body: JSON.stringify({ @@ -148,8 +153,8 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "owner-connection diagnostics boundary test", - source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id"], name: streamName }], + source: { id: sourceId, kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: [instanceId], name: streamName }], type: "https://pdpp.dev/data-access", }, ], @@ -159,10 +164,24 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName method: "POST", }) ).body as { request_uri?: string }; + assert.ok(par.request_uri); + const review = ( + await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }) + ).body as { approval_review?: object; approval_review_revision?: string; request_uri?: string }; + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, par.request_uri); const approved = ( await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }) ).body as { token?: string }; @@ -599,7 +618,12 @@ test("owner-agent diagnostics rejects a client grant token with 403 and audits i // biome-ignore lint/style/useDestructuring: localized test assertion preserves its explicit contract. const firstStream = manifest.streams[0]; assert.ok(firstStream, "manifest carries at least one stream"); - const clientToken = await approveClientGrant(asUrl, connectorKey, firstStream.name); + const clientToken = await approveClientGrant( + asUrl, + manifest.connector_id, + firstStream.name, + "cin_spotify_personal" + ); const { status, diff --git a/reference-implementation/test/owner-connection-intent.test.ts b/reference-implementation/test/owner-connection-intent.test.ts index a9c320e38..f1f3e80be 100644 --- a/reference-implementation/test/owner-connection-intent.test.ts +++ b/reference-implementation/test/owner-connection-intent.test.ts @@ -137,7 +137,12 @@ async function issueOwnerToken(asUrl: string, subjectId = OWNER_SUBJECT_ID): Pro // PAR + consent yields a grant-scoped client-kind bearer (pdpp_token_kind: // "client"). These must NOT reach the owner-agent control surface. -async function approveClientGrant(asUrl: string, connectorId: string, streamName: string): Promise<string> { +async function approveClientGrant( + asUrl: string, + sourceId: string, + streamName: string, + instanceId: string +): Promise<string> { const par = ( await fetchJson(`${asUrl}/oauth/par`, { body: JSON.stringify({ @@ -146,8 +151,8 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "owner-connection intent boundary test", - source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id"], name: streamName }], + source: { id: sourceId, kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: [instanceId], name: streamName }], type: "https://pdpp.dev/data-access", }, ], @@ -157,10 +162,24 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName method: "POST", }) ).body as { request_uri?: string }; + assert.ok(par.request_uri); + const review = ( + await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }) + ).body as { approval_review?: object; approval_review_revision?: string; request_uri?: string }; + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, par.request_uri); const approved = ( await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }) ).body as { token?: string }; @@ -261,14 +280,39 @@ function loadPackageManifest(name: string): PackageManifest { ) as PackageManifest; } +function withExplicitTestSourceDeclaration(manifest: PackageManifest): PackageManifest { + if (manifest.source_declaration && typeof manifest.source_declaration === "object") { + return manifest; + } + const connectorKey = canonicalConnectorKey(manifest.connector_id) ?? manifest.connector_id; + const streams = Array.isArray(manifest.streams) + ? manifest.streams.map((stream) => ({ + ...stream, + ...(stream.semantics === "append" ? { semantics: "append_only" } : {}), + })) + : []; + return { + ...manifest, + source_declaration: { + declaration_version: `owner-connection-intent-test:${connectorKey}:v1`, + display: { name: typeof manifest.display_name === "string" ? manifest.display_name : connectorKey }, + protocol_version: manifest.protocol_version, + publisher: { id: "https://pdpp.dev/reference-implementation/tests" }, + source: { id: `https://sources.example/connectors/${encodeURIComponent(connectorKey)}`, kind: "connector" }, + streams, + }, + }; +} + async function registerConnector(asUrl: string, manifest: PackageManifest): Promise<PackageManifest> { + const registeredManifest = withExplicitTestSourceDeclaration(manifest); const resp = await fetch(`${asUrl}/connectors`, { - body: JSON.stringify(manifest), + body: JSON.stringify(registeredManifest), headers: { "Content-Type": "application/json" }, method: "POST", }); const text = await resp.text(); - assert.equal(resp.status, 201, `register ${manifest.connector_id} failed: ${resp.status} ${text}`); + assert.equal(resp.status, 201, `register ${registeredManifest.connector_id} failed: ${resp.status} ${text}`); return manifest; } @@ -864,8 +908,14 @@ test("owner-agent intent rejects a client grant token with 403 and audits the fa headers: { "Content-Type": "application/json" }, method: "POST", }); + await seedInstance({ + connectorId: "codex", + connectorInstanceId: "cin_codex_client_auth", + displayName: "Codex auth fixture", + sourceBindingKey: "the owner@example.com", + }); const streamName = manifest.streams?.[0]?.name || "sessions"; - const clientToken = await approveClientGrant(asUrl, "codex", streamName); + const clientToken = await approveClientGrant(asUrl, manifest.connector_id, streamName, "cin_codex_client_auth"); const { status, body: rawBody, resp } = await createIntent(rsUrl, clientToken, { connector_id: "codex" }); const body = rawBody as IntentResponseBody; diff --git a/reference-implementation/test/owner-connection-reactivate.test.ts b/reference-implementation/test/owner-connection-reactivate.test.ts index 4bfe224ce..0a8fc00bf 100644 --- a/reference-implementation/test/owner-connection-reactivate.test.ts +++ b/reference-implementation/test/owner-connection-reactivate.test.ts @@ -138,7 +138,12 @@ async function issueOwnerToken(asUrl: string, subjectId = OWNER_SUBJECT_ID): Pro // PAR + consent yields a grant-scoped client-kind bearer (pdpp_token_kind: "client"). // These must NOT reach the owner-agent control surface. -async function approveClientGrant(asUrl: string, connectorId: string, streamName: string): Promise<string> { +async function approveClientGrant( + asUrl: string, + sourceId: string, + streamName: string, + instanceId: string +): Promise<string> { const par = ( await fetchJson(`${asUrl}/oauth/par`, { body: JSON.stringify({ @@ -147,8 +152,8 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "owner-connection reactivate boundary test", - source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id"], name: streamName }], + source: { id: sourceId, kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: [instanceId], name: streamName }], type: "https://pdpp.dev/data-access", }, ], @@ -158,10 +163,24 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName method: "POST", }) ).body as { request_uri?: string }; + assert.ok(par.request_uri); + const review = ( + await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }) + ).body as { approval_review?: object; approval_review_revision?: string; request_uri?: string }; + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, par.request_uri); const approved = ( await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }) ).body as { token?: string }; @@ -486,14 +505,22 @@ test("client grant token cannot reactivate (403)", async () => { sourceBindingKey: "the owner@example.com", }); - // Revoke it via owner token first. - const ownerToken = await issueOwnerToken(asUrl); - await postRevoke(rsUrl, ownerToken, "/v1/owner/connections/cin_spotify_client_test/revoke"); - // Client grant must not reach reactivate. const [clientStream] = manifest.streams; assert.ok(clientStream, "manifest carries at least one stream"); - const clientToken = await approveClientGrant(asUrl, connectorKey, clientStream.name); + const clientToken = await approveClientGrant( + asUrl, + manifest.connector_id, + clientStream.name, + "cin_spotify_client_test" + ); + + // Revoke it via owner token after minting the client grant. Approval only + // authorizes active instances, but the negative-auth assertion targets the + // owner-control boundary, not consent eligibility. + const ownerToken = await issueOwnerToken(asUrl); + await postRevoke(rsUrl, ownerToken, "/v1/owner/connections/cin_spotify_client_test/revoke"); + const result = await fetchJson(`${rsUrl}/v1/owner/connections/cin_spotify_client_test/reactivate`, { headers: { Authorization: `Bearer ${clientToken}`, "Content-Type": "application/json" }, method: "POST", diff --git a/reference-implementation/test/owner-connection-rename.test.ts b/reference-implementation/test/owner-connection-rename.test.ts index c6987c840..45640a025 100644 --- a/reference-implementation/test/owner-connection-rename.test.ts +++ b/reference-implementation/test/owner-connection-rename.test.ts @@ -133,7 +133,12 @@ async function issueOwnerToken(asUrl: string, subjectId = OWNER_SUBJECT_ID): Pro // PAR + consent yields a grant-scoped client-kind bearer (pdpp_token_kind: // "client"). These must NOT reach the owner-agent control surface. -async function approveClientGrant(asUrl: string, connectorId: string, streamName: string): Promise<string> { +async function approveClientGrant( + asUrl: string, + sourceId: string, + streamName: string, + instanceId: string +): Promise<string> { const par = ( await fetchJson(`${asUrl}/oauth/par`, { body: JSON.stringify({ @@ -142,8 +147,8 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "owner-connection rename boundary test", - source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id"], name: streamName }], + source: { id: sourceId, kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: [instanceId], name: streamName }], type: "https://pdpp.dev/data-access", }, ], @@ -153,10 +158,24 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName method: "POST", }) ).body as { request_uri?: string }; + assert.ok(par.request_uri); + const review = ( + await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }) + ).body as { approval_review?: object; approval_review_revision?: string; request_uri?: string }; + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, par.request_uri); const approved = ( await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }) ).body as { token?: string }; @@ -623,7 +642,7 @@ test("owner-agent rename rejects a client grant token with 403", async () => { }); const firstStream = manifest.streams?.[0]; assert.ok(firstStream, "manifest carries at least one stream"); - const clientToken = await approveClientGrant(asUrl, connectorKey, firstStream.name); + const clientToken = await approveClientGrant(asUrl, manifest.connector_id, firstStream.name, "cin_amazon_personal"); const { status, diff --git a/reference-implementation/test/owner-connection-revoke.test.ts b/reference-implementation/test/owner-connection-revoke.test.ts index 30fdff9f3..e67569108 100644 --- a/reference-implementation/test/owner-connection-revoke.test.ts +++ b/reference-implementation/test/owner-connection-revoke.test.ts @@ -218,7 +218,12 @@ async function issueOwnerToken(asUrl: string, subjectId = OWNER_SUBJECT_ID): Pro // PAR + consent yields a grant-scoped client-kind bearer (pdpp_token_kind: // "client"). These must NOT reach the owner-agent control surface. -async function approveClientGrant(asUrl: string, connectorId: string, streamName: string): Promise<string> { +async function approveClientGrant( + asUrl: string, + sourceId: string, + streamName: string, + instanceId: string +): Promise<string> { const par = ( await fetchJson(`${asUrl}/oauth/par`, { body: JSON.stringify({ @@ -227,8 +232,8 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "owner-connection revoke boundary test", - source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id"], name: streamName }], + source: { id: sourceId, kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: [instanceId], name: streamName }], type: "https://pdpp.dev/data-access", }, ], @@ -238,10 +243,23 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName method: "POST", }) ).body as { request_uri: string }; + const review = ( + await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }) + ).body as { approval_review?: object; approval_review_revision?: string; request_uri?: string }; + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, par.request_uri); const approved = ( await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }) ).body as { token?: string }; @@ -651,7 +669,12 @@ test("owner-agent revoke rejects a client grant token with 403 and audits it", a displayName: "My Spotify", sourceBindingKey: "the owner@example.com", }); - const clientToken = await approveClientGrant(asUrl, connectorKey, mustFirstStreamName(manifest)); + const clientToken = await approveClientGrant( + asUrl, + manifest.connector_id, + mustFirstStreamName(manifest), + "cin_spotify_personal" + ); const { status, body, resp } = await postRevoke( rsUrl, diff --git a/reference-implementation/test/owner-connection-run.test.ts b/reference-implementation/test/owner-connection-run.test.ts index d9b51f012..7db6a01ac 100644 --- a/reference-implementation/test/owner-connection-run.test.ts +++ b/reference-implementation/test/owner-connection-run.test.ts @@ -226,7 +226,12 @@ async function issueOwnerToken(asUrl: string, subjectId = OWNER_SUBJECT_ID): Pro // PAR + consent yields a grant-scoped client-kind bearer (pdpp_token_kind: // "client"). These must NOT reach the owner-agent control surface. -async function approveClientGrant(asUrl: string, connectorId: string, streamName: string): Promise<string> { +async function approveClientGrant( + asUrl: string, + sourceId: string, + streamName: string, + instanceId: string +): Promise<string> { const par = ( await fetchJson(`${asUrl}/oauth/par`, { body: JSON.stringify({ @@ -235,8 +240,8 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "owner-connection run boundary test", - source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id"], name: streamName }], + source: { id: sourceId, kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: [instanceId], name: streamName }], type: "https://pdpp.dev/data-access", }, ], @@ -246,10 +251,23 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName method: "POST", }) ).body as { request_uri: string }; + const review = ( + await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }) + ).body as { approval_review?: object; approval_review_revision?: string; request_uri?: string }; + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, par.request_uri); const approved = ( await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }) ).body as { token?: string }; @@ -575,7 +593,12 @@ test("owner-agent run rejects a client grant token with 403 and audits it", asyn displayName: "My Spotify", sourceBindingKey: "the owner@example.com", }); - const clientToken = await approveClientGrant(asUrl, connectorKey, mustFirstStreamName(manifest)); + const clientToken = await approveClientGrant( + asUrl, + manifest.connector_id, + mustFirstStreamName(manifest), + "cin_spotify_personal" + ); const { status, body, resp } = await postRun(rsUrl, clientToken, "/v1/owner/connections/cin_spotify_personal/run"); assert.equal(status, 403); diff --git a/reference-implementation/test/owner-connection-schedule.test.ts b/reference-implementation/test/owner-connection-schedule.test.ts index a9c174ca7..2e3e35a64 100644 --- a/reference-implementation/test/owner-connection-schedule.test.ts +++ b/reference-implementation/test/owner-connection-schedule.test.ts @@ -173,7 +173,12 @@ async function issueOwnerToken(asUrl: string, subjectId = OWNER_SUBJECT_ID): Pro // PAR + consent yields a grant-scoped client-kind bearer (pdpp_token_kind: // "client"). These must NOT reach the owner-agent control surface. -async function approveClientGrant(asUrl: string, connectorId: string, streamName: string): Promise<string> { +async function approveClientGrant( + asUrl: string, + sourceId: string, + streamName: string, + instanceId: string +): Promise<string> { const par = ( await fetchJson(`${asUrl}/oauth/par`, { body: JSON.stringify({ @@ -182,8 +187,8 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "owner-connection schedule boundary test", - source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id"], name: streamName }], + source: { id: sourceId, kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: [instanceId], name: streamName }], type: "https://pdpp.dev/data-access", }, ], @@ -193,10 +198,23 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName method: "POST", }) ).body as { request_uri: string }; + const review = ( + await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }) + ).body as { approval_review?: object; approval_review_revision?: string; request_uri?: string }; + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, par.request_uri); const approved = ( await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }) ).body as { token?: string }; @@ -601,7 +619,12 @@ test("owner-agent schedule action rejects a client grant token with 403 and audi sourceBindingKey: "the owner@example.com", }); await seedSchedule({ connectorId: connectorKey, connectorInstanceId: "cin_spotify_personal", enabled: true }); - const clientToken = await approveClientGrant(asUrl, connectorKey, mustFirstStreamName(manifest)); + const clientToken = await approveClientGrant( + asUrl, + manifest.connector_id, + mustFirstStreamName(manifest), + "cin_spotify_personal" + ); const { status, body, resp } = await postSchedule( rsUrl, @@ -817,7 +840,12 @@ test("owner-agent delete rejects a client grant token with 403 and audits it", a sourceBindingKey: "the owner@example.com", }); await seedSchedule({ connectorId: connectorKey, connectorInstanceId: "cin_spotify_personal", enabled: true }); - const clientToken = await approveClientGrant(asUrl, connectorKey, mustFirstStreamName(manifest)); + const clientToken = await approveClientGrant( + asUrl, + manifest.connector_id, + mustFirstStreamName(manifest), + "cin_spotify_personal" + ); const { status, body, resp } = await deleteSchedule( rsUrl, diff --git a/reference-implementation/test/owner-connections-list.test.ts b/reference-implementation/test/owner-connections-list.test.ts index ae282a319..f8e90ee5a 100644 --- a/reference-implementation/test/owner-connections-list.test.ts +++ b/reference-implementation/test/owner-connections-list.test.ts @@ -139,7 +139,12 @@ async function issueOwnerToken(asUrl: string, subjectId: string = OWNER_SUBJECT_ // PAR + consent yields a grant-scoped client-kind bearer (pdpp_token_kind: // "client"). These must NOT reach the owner-agent control surface. -async function approveClientGrant(asUrl: string, connectorId: string, streamName: string): Promise<string> { +async function approveClientGrant( + asUrl: string, + sourceId: string, + streamName: string, + instanceId: string +): Promise<string> { const par = asRecord( ( await fetchJson(`${asUrl}/oauth/par`, { @@ -149,8 +154,8 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "owner-connections boundary test", - source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id"], name: streamName }], + source: { id: sourceId, kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: [instanceId], name: streamName }], type: "https://pdpp.dev/data-access", }, ], @@ -161,11 +166,27 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName }) ).body ); + assert.ok(par.request_uri); + const review = asRecord( + ( + await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }) + ).body + ); + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, par.request_uri); const approved = asRecord( ( await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }) ).body @@ -570,7 +591,12 @@ test("owner-agent connection listing rejects a client grant token with 403", asy // A client grant needs a stream to scope to; amazon's first stream suffices. const { streams } = manifest; const streamName = String(asRecord(asArray(streams)[0]).name); - const clientToken = await approveClientGrant(asUrl, connectorKey, streamName); + const clientToken = await approveClientGrant( + asUrl, + String(manifest.connector_id), + streamName, + "cin_amazon_personal" + ); const { status, body } = await fetchJson(`${rsUrl}/v1/owner/connections`, { headers: { Authorization: `Bearer ${clientToken}` }, diff --git a/reference-implementation/test/owner-connector-templates.test.ts b/reference-implementation/test/owner-connector-templates.test.ts index ee1c4f61a..3cfe7738e 100644 --- a/reference-implementation/test/owner-connector-templates.test.ts +++ b/reference-implementation/test/owner-connector-templates.test.ts @@ -117,7 +117,12 @@ async function issueOwnerToken(asUrl: string, subjectId: string = OWNER_SUBJECT_ return String(tok.access_token); } -async function approveClientGrant(asUrl: string, connectorId: string, streamName: string): Promise<string> { +async function approveClientGrant( + asUrl: string, + sourceId: string, + streamName: string, + instanceId: string +): Promise<string> { const par = asRecord( ( await fetchJson(`${asUrl}/oauth/par`, { @@ -127,8 +132,8 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "owner-connector-template boundary test", - source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id"], name: streamName }], + source: { id: sourceId, kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: [instanceId], name: streamName }], type: "https://pdpp.dev/data-access", }, ], @@ -139,11 +144,27 @@ async function approveClientGrant(asUrl: string, connectorId: string, streamName }) ).body ); + assert.ok(par.request_uri); + const review = asRecord( + ( + await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }) + ).body + ); + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, par.request_uri); const approved = asRecord( ( await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }) ).body @@ -299,7 +320,18 @@ test("client grant bearer cannot list owner connector templates", async () => { const manifest = await registerConnector(asUrl, loadManifest("spotify")); const connectorKey = canonicalConnectorKey(manifest.connector_id); assert.ok(connectorKey, "spotify manifest must resolve a canonical connector key"); - const clientToken = await approveClientGrant(asUrl, connectorKey, "saved_tracks"); + await seedInstance({ + connectorId: connectorKey, + connectorInstanceId: "cin_spotify_template_auth", + displayName: "Spotify auth fixture", + sourceBindingKey: "the owner@example.com", + }); + const clientToken = await approveClientGrant( + asUrl, + String(manifest.connector_id), + "saved_tracks", + "cin_spotify_template_auth" + ); const { status, body } = await fetchJson(`${rsUrl}/v1/owner/connector-templates`, { headers: { Authorization: `Bearer ${clientToken}` }, }); diff --git a/reference-implementation/test/owner-control-entrypoint.test.ts b/reference-implementation/test/owner-control-entrypoint.test.ts index ee030b024..752d85e64 100644 --- a/reference-implementation/test/owner-control-entrypoint.test.ts +++ b/reference-implementation/test/owner-control-entrypoint.test.ts @@ -28,7 +28,9 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; const TOP_LEVEL_REGEX_1 = /test-event/; const TOP_LEVEL_REGEX_2 = /resume/; @@ -176,9 +178,43 @@ async function registerConnector(asUrl: string, manifest: Record<string, unknown return manifest; } +interface SeedInstanceInput { + connectorId: string; + connectorInstanceId: string; + displayName: string; + sourceBindingKey: string; +} + +async function seedInstance({ + connectorId, + connectorInstanceId, + displayName, + sourceBindingKey, +}: SeedInstanceInput): Promise<void> { + const now = "2026-06-01T00:00:00.000Z"; + const store = createSqliteConnectorInstanceStore(); + await store.upsert({ + connectorId, + connectorInstanceId, + createdAt: now, + displayName, + ownerSubjectId: OWNER_SUBJECT_ID, + sourceBinding: { account_hint: sourceBindingKey }, + sourceBindingKey, + sourceKind: "account", + status: "active", + updatedAt: now, + }); +} + // PAR + consent yields a grant-scoped client-kind bearer. It must NOT reach the // owner-agent control entrypoint. Scopes to a real registered connector/stream. -async function approveClientGrant(asUrl: string, connectorId: unknown, streamName: unknown): Promise<string> { +async function approveClientGrant( + asUrl: string, + sourceId: unknown, + streamName: unknown, + instanceId: string +): Promise<string> { const par = asRecord( ( await fetchJson(`${asUrl}/oauth/par`, { @@ -188,8 +224,8 @@ async function approveClientGrant(asUrl: string, connectorId: unknown, streamNam access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "owner-control boundary test", - source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id"], name: streamName }], + source: { id: sourceId, kind: "connector" }, + streams: [{ fields: ["id"], instance_ids: [instanceId], name: streamName }], type: "https://pdpp.dev/data-access", }, ], @@ -200,11 +236,27 @@ async function approveClientGrant(asUrl: string, connectorId: unknown, streamNam }) ).body ); + assert.ok(par.request_uri); + const review = asRecord( + ( + await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }) + ).body + ); + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, par.request_uri); const approved = asRecord( ( await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: par.request_uri, subject_id: OWNER_SUBJECT_ID }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }) ).body @@ -426,8 +478,16 @@ test("control document names every family with a typed status and a non-empty re test("owner-agent control entrypoint rejects a client grant token with 403", async () => { await withServer(async ({ asUrl, rsUrl }) => { const manifest = await registerConnector(asUrl, loadManifest("amazon")); + const connectorKey = canonicalConnectorKey(manifest.connector_id); + assert.ok(connectorKey, "amazon manifest must resolve a canonical connector key"); + await seedInstance({ + connectorId: connectorKey, + connectorInstanceId: "cin_amazon_entrypoint", + displayName: "Amazon auth fixture", + sourceBindingKey: "the owner@example.com", + }); const streamName = asRecord(asArray(manifest.streams)[0]).name; - const clientToken = await approveClientGrant(asUrl, manifest.connector_id, streamName); + const clientToken = await approveClientGrant(asUrl, manifest.connector_id, streamName, "cin_amazon_entrypoint"); const { status, body } = await fetchJson(`${rsUrl}/v1/owner/control`, { headers: { Authorization: `Bearer ${clientToken}` }, }); diff --git a/reference-implementation/test/owner-csrf.test.ts b/reference-implementation/test/owner-csrf.test.ts index 5bd87d4b0..64f39d14d 100644 --- a/reference-implementation/test/owner-csrf.test.ts +++ b/reference-implementation/test/owner-csrf.test.ts @@ -24,8 +24,10 @@ import { getOwnerDeviceAuthorizationByUserCode as getOwnerDeviceAuthorizationByUserCodeUntyped, initiateOwnerDeviceAuthorization as initiateOwnerDeviceAuthorizationUntyped, } from "../server/auth.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer as startServerUntyped } from "../server/index.ts"; import { deriveOwnerCsrfSecretFromString, issueOwnerCsrfToken } from "../server/owner-csrf.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; // server/index.js (startServer) and server/auth.ts (device authorization) // are untyped JS (allowJs, checkJs:false). Local interfaces model only @@ -74,7 +76,10 @@ const SPOTIFY_MANIFEST: { connector_id: string; [key: string]: unknown } = JSON. ); const TEST_PASSWORD = "csrf-regression-test-password"; +const OWNER_SUBJECT_ID = "owner_local"; +const NOW = "2026-05-31T00:00:00.000Z"; const CSRF_FIELD_PATTERN = /<input type="hidden" name="_csrf" value="([^"]+)"\s*\/>/; +const APPROVAL_REVIEW_REVISION_PATTERN = /name="approval_review_revision" value="([^"]+)"/; async function closeServer(server: ClosableServer): Promise<void> { server.asServer.closeAllConnections(); @@ -216,6 +221,7 @@ async function startPendingConsent(asUrl: string): Promise<string> { if (!registerResp.ok && registerResp.status !== 409) { throw new Error(`connector registration failed: ${registerResp.status} ${await registerResp.text()}`); } + await seedSpotifyInstance(); const resp = await fetch(`${asUrl}/oauth/par`, { body: JSON.stringify({ authorization_details: [ @@ -241,6 +247,42 @@ async function startPendingConsent(asUrl: string): Promise<string> { return body.request_uri; } +async function reviewPendingConsent(asUrl: string, requestUri: string, sessionCookie: string): Promise<string> { + const resp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri }), + headers: { Accept: "application/json", "Content-Type": "application/json", Cookie: sessionCookie }, + method: "POST", + }); + const text = await resp.text(); + assert.equal(resp.status, 200, text); + const body = JSON.parse(text) as { + approval_review?: object; + approval_review_revision?: string; + request_uri?: string; + }; + assert.ok(body.approval_review); + assert.ok(body.approval_review_revision); + assert.equal(body.request_uri, requestUri); + return body.approval_review_revision; +} + +async function seedSpotifyInstance(): Promise<void> { + const connectorId = canonicalConnectorKey(SPOTIFY_MANIFEST.connector_id); + assert.ok(connectorId, "spotify manifest must resolve to a canonical connector key"); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: "cin_owner_csrf_spotify", + createdAt: NOW, + displayName: "Owner CSRF Spotify", + ownerSubjectId: OWNER_SUBJECT_ID, + sourceBinding: { account_hint: "owner-csrf@example.com" }, + sourceBindingKey: "owner-csrf@example.com", + sourceKind: "account", + status: "active", + updatedAt: NOW, + }); +} + // ── login: form POST without CSRF -> 403 + no session ──────────────────────── test("CSRF: form POST /owner/login without _csrf is rejected with 403 and issues no session", async () => { await withServer({ ownerAuthPassword: TEST_PASSWORD }, async ({ asUrl }) => { @@ -322,7 +364,6 @@ test("CSRF: form POST /consent/approve without _csrf is rejected with 403 even w await withServer({ ownerAuthPassword: TEST_PASSWORD }, async ({ asUrl }) => { const { sessionCookie } = await login(asUrl, TEST_PASSWORD); const requestUri = await startPendingConsent(asUrl); - const resp = await fetch(`${asUrl}/consent/approve`, { body: new URLSearchParams({ request_uri: requestUri }).toString(), headers: { @@ -410,9 +451,26 @@ test("CSRF: matching token from /consent GET allows /consent/approve form POST", sessionCookie ); assert.ok(csrf.csrfField, "consent GET SHALL embed a CSRF token"); + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: new URLSearchParams({ _csrf: csrf.csrfField, request_uri: requestUri }).toString(), + headers: { + Accept: "text/html", + "Content-Type": "application/x-www-form-urlencoded", + Cookie: `${sessionCookie}; ${csrf.csrfCookie}`, + }, + method: "POST", + }); + const reviewHtml = await reviewResp.text(); + assert.equal(reviewResp.status, 200, reviewHtml); + const revisionMatch = reviewHtml.match(APPROVAL_REVIEW_REVISION_PATTERN); + assert.ok(revisionMatch?.[1], "review form SHALL carry an approval review revision"); const resp = await fetch(`${asUrl}/consent/approve`, { - body: new URLSearchParams({ _csrf: csrf.csrfField, request_uri: requestUri }).toString(), + body: new URLSearchParams({ + _csrf: csrf.csrfField, + approval_review_revision: revisionMatch[1], + request_uri: requestUri, + }).toString(), headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded", @@ -421,8 +479,8 @@ test("CSRF: matching token from /consent GET allows /consent/approve form POST", method: "POST", redirect: "manual", }); - assert.equal(resp.status, 200); const text = await resp.text(); + assert.equal(resp.status, 200, text); assert.ok(text.includes("Access approved")); }); }); @@ -460,8 +518,9 @@ test("CSRF: JSON POST /consent/approve remains compatible without _csrf", async await withServer({ ownerAuthPassword: TEST_PASSWORD }, async ({ asUrl }) => { const { sessionCookie } = await login(asUrl, TEST_PASSWORD); const requestUri = await startPendingConsent(asUrl); + const approvalReviewRevision = await reviewPendingConsent(asUrl, requestUri, sessionCookie ?? ""); const resp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: requestUri }), + body: JSON.stringify({ approval_review_revision: approvalReviewRevision, request_uri: requestUri }), headers: { Accept: "application/json", "Content-Type": "application/json", @@ -532,8 +591,9 @@ test("CSRF: text/plain POST /consent/approve without _csrf is rejected with 403 // Confirm no grant was actually issued: a fresh approval through // the JSON branch with the same pending request SHALL still // succeed (the pending row was not consumed). + const approvalReviewRevision = await reviewPendingConsent(asUrl, requestUri, sessionCookie ?? ""); const recover = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: requestUri }), + body: JSON.stringify({ approval_review_revision: approvalReviewRevision, request_uri: requestUri }), headers: { Accept: "application/json", "Content-Type": "application/json", diff --git a/reference-implementation/test/owner-device-approval-atomicity.test.ts b/reference-implementation/test/owner-device-approval-atomicity.test.ts new file mode 100644 index 000000000..e7c11b14c --- /dev/null +++ b/reference-implementation/test/owner-device-approval-atomicity.test.ts @@ -0,0 +1,386 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + type AuthorizationDecisionFaultHook, + approveOwnerDeviceAuthorization, + denyOwnerDeviceAuthorization, + exchangeOwnerDeviceCode, + initiateOwnerDeviceAuthorization, + introspect, + type OwnerDeviceApprovalFaultHook, + registerDynamicClient, + seedPreRegisteredClients, +} from "../server/auth.ts"; +import { closeDb, getDb, initDb } from "../server/db.ts"; + +const CLIENT_ID = "owner_device_atomicity_client"; +const FORCED_AFTER_TOKEN_INSERT_RE = /forced after_token_insert/; +const FORCED_BEFORE_TOKEN_INSERT_RE = /forced before_token_insert/; +const FORCED_DENIAL_EVENT_RE = /forced denial event rollback/; + +interface StartedOwnerDeviceAuth { + device_code: string; + user_code: string; +} + +function setupSqliteAuth(path = ":memory:") { + initDb(path); + return seedPreRegisteredClients([ + { + client_id: CLIENT_ID, + metadata: { + client_name: "Owner Device Atomicity Client", + token_endpoint_auth_method: "none", + }, + }, + ]); +} + +async function startOwnerDeviceAuth(): Promise<StartedOwnerDeviceAuth> { + const started = await initiateOwnerDeviceAuthorization(CLIENT_ID, { + expiresIn: 300, + interval: 1, + }); + assert.equal(typeof started.device_code, "string"); + assert.equal(typeof started.user_code, "string"); + return { + device_code: String(started.device_code), + user_code: String(started.user_code), + }; +} + +function countRows(table: string, where = "1 = 1"): number { + const row = getDb().prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE ${where}`).get() as { count: number }; + return row.count; +} + +function ownerDeviceRow(deviceCode: string): { status: string; subject_id: string | null; token_id: string | null } { + const row = getDb() + .prepare("SELECT status, subject_id, token_id FROM owner_device_auth WHERE device_code = ?") + .get(deviceCode) as { status: string; subject_id: string | null; token_id: string | null } | undefined; + assert.ok(row, "owner_device_auth row must exist"); + return row; +} + +function countOwnerTokensForClient(): number { + return countRows("tokens", `client_id = '${CLIENT_ID}' AND token_kind = 'owner'`); +} + +function countOwnerDeviceEvents(deviceCode: string, eventType: string): number { + return countRows( + "spine_events", + `object_id = '${deviceCode}' AND object_type = 'owner_device_auth' AND event_type = '${eventType}'` + ); +} + +function clientIssuerSubject(clientId: string): string | null { + const row = getDb().prepare("SELECT metadata_json FROM oauth_clients WHERE client_id = ?").get(clientId) as + | { metadata_json: string } + | undefined; + assert.ok(row, "oauth client row must exist"); + const metadata = JSON.parse(row.metadata_json) as { issuer_subject_id?: string }; + return metadata.issuer_subject_id || null; +} + +function throwingHook(stageToThrow: Parameters<OwnerDeviceApprovalFaultHook>[0]): OwnerDeviceApprovalFaultHook { + return (stage) => { + if (stage === stageToThrow) { + throw Object.assign(new Error(`forced ${stage}`), { code: `forced_${stage}` }); + } + }; +} + +function createPause(): { paused: Promise<void>; release: () => void; hook: () => Promise<void> } { + let release: () => void = () => undefined; + let markPaused: () => void = () => undefined; + const paused = new Promise<void>((resolve) => { + markPaused = resolve; + }); + const resumed = new Promise<void>((resolve) => { + release = resolve; + }); + return { + hook: async () => { + markPaused(); + await resumed; + }, + paused, + release, + }; +} + +test.afterEach(() => { + closeDb(); +}); + +test("owner-device approval rolls back when token insertion has not started", async () => { + await setupSqliteAuth(); + const started = await startOwnerDeviceAuth(); + + await assert.rejects( + approveOwnerDeviceAuthorization(started.user_code, "owner_local", { + faultHook: throwingHook("before_token_insert"), + }), + FORCED_BEFORE_TOKEN_INSERT_RE + ); + + assert.deepEqual(ownerDeviceRow(started.device_code), { status: "pending", subject_id: null, token_id: null }); + assert.equal(countOwnerTokensForClient(), 0, "no owner token is persisted"); + assert.equal(countOwnerDeviceEvents(started.device_code, "consent.approved"), 0, "approval event rolls back"); +}); + +test("owner-device approval rolls back token insert and events on mid-transaction failure", async () => { + await setupSqliteAuth(); + const started = await startOwnerDeviceAuth(); + + await assert.rejects( + approveOwnerDeviceAuthorization(started.user_code, "owner_local", { + faultHook: throwingHook("after_token_insert"), + }), + FORCED_AFTER_TOKEN_INSERT_RE + ); + + assert.deepEqual(ownerDeviceRow(started.device_code), { status: "pending", subject_id: null, token_id: null }); + assert.equal(countOwnerTokensForClient(), 0, "inserted owner token is rolled back"); + assert.equal(countOwnerDeviceEvents(started.device_code, "consent.approved"), 0, "approval event rolls back"); + assert.equal(countRows("spine_events", "event_type = 'token.issued'"), 0, "token event rolls back"); +}); + +test("owner-device approval retry after rollback mints exactly one introspectable owner token", async () => { + await setupSqliteAuth(); + const started = await startOwnerDeviceAuth(); + + await assert.rejects( + approveOwnerDeviceAuthorization(started.user_code, "owner_local", { + faultHook: throwingHook("after_token_insert"), + }), + FORCED_AFTER_TOKEN_INSERT_RE + ); + const approved = await approveOwnerDeviceAuthorization(started.user_code, "owner_local"); + assert.equal(typeof approved.access_token, "string"); + + assert.deepEqual(ownerDeviceRow(started.device_code), { + status: "approved", + subject_id: "owner_local", + token_id: approved.access_token, + }); + assert.equal(countOwnerTokensForClient(), 1); + assert.equal(countOwnerDeviceEvents(started.device_code, "consent.approved"), 1); + assert.equal(countRows("spine_events", "event_type = 'token.issued'"), 1); + + const tokenState = await introspect(approved.access_token); + assert.equal(tokenState.active, true, "bound owner token introspects active"); + assert.equal(tokenState.pdpp_token_kind, "owner"); +}); + +test("owner-device dynamic client binding rolls back with failed approval", async () => { + initDb(); + const registered = await registerDynamicClient({ + client_name: "Owner Device Dynamic Client", + token_endpoint_auth_method: "none", + }); + const clientId = String(registered.client_id); + const started = await initiateOwnerDeviceAuthorization(clientId, { + expiresIn: 300, + interval: 1, + }); + assert.equal(clientIssuerSubject(clientId), null); + + await assert.rejects( + approveOwnerDeviceAuthorization(started.user_code, "owner_A", { + faultHook: throwingHook("after_token_insert"), + }), + FORCED_AFTER_TOKEN_INSERT_RE + ); + + assert.equal(clientIssuerSubject(clientId), null, "dynamic subject stamp rolls back with approval failure"); + const recovered = await approveOwnerDeviceAuthorization(started.user_code, "owner_A"); + assert.equal(clientIssuerSubject(clientId), "owner_A", "retry binds dynamic client in the successful transaction"); + assert.equal(recovered.subject_id, "owner_A"); +}); + +test("owner-device approval is idempotent across concurrent approval and response-loss retry", async () => { + await setupSqliteAuth(); + const started = await startOwnerDeviceAuth(); + + const approvals = await Promise.all( + Array.from({ length: 16 }, () => approveOwnerDeviceAuthorization(started.user_code, "owner_local")) + ); + const tokens = new Set(approvals.map((approval) => approval.access_token)); + assert.equal(tokens.size, 1, "all concurrent approvals return the same bound token"); + const token = approvals[0]?.access_token; + assert.equal(typeof token, "string"); + + const retry = await approveOwnerDeviceAuthorization(started.user_code, "owner_local"); + assert.equal(retry.access_token, token, "retry after lost response returns the original token"); + assert.equal(countOwnerTokensForClient(), 1, "no remint on concurrent calls or retry"); + assert.equal(countOwnerDeviceEvents(started.device_code, "consent.approved"), 1, "approval event is emitted once"); + assert.equal(countRows("spine_events", "event_type = 'token.issued'"), 1, "token.issued event is emitted once"); + + const exchanged = await exchangeOwnerDeviceCode({ + clientId: CLIENT_ID, + deviceCode: started.device_code, + }); + assert.equal(exchanged.access_token, token, "device-code exchange returns the same approved token"); +}); + +test("owner-device approval recovery rejects a different authenticated subject", async () => { + await setupSqliteAuth(); + const started = await startOwnerDeviceAuth(); + + const ownerA = await approveOwnerDeviceAuthorization(started.user_code, "owner_A"); + assert.equal(ownerA.subject_id, "owner_A"); + + await assert.rejects( + approveOwnerDeviceAuthorization(started.user_code, "owner_B"), + (err: unknown) => err instanceof Error && "code" in err && err.code === "not_found" + ); + + assert.deepEqual(ownerDeviceRow(started.device_code), { + status: "approved", + subject_id: "owner_A", + token_id: ownerA.access_token, + }); + assert.equal(countOwnerTokensForClient(), 1, "cross-subject recovery does not remint"); + const tokenState = await introspect(ownerA.access_token); + assert.equal(tokenState.active, true, "original owner token remains active"); + assert.equal(tokenState.subject_id, "owner_A"); +}); + +test("owner-device approval allows only the claimed subject under mixed concurrent calls", async () => { + await setupSqliteAuth(); + const started = await startOwnerDeviceAuth(); + + const attempts = await Promise.allSettled( + Array.from({ length: 16 }, (_, index) => + approveOwnerDeviceAuthorization(started.user_code, index % 2 === 0 ? "owner_A" : "owner_B") + ) + ); + const approvals = attempts + .filter((attempt): attempt is PromiseFulfilledResult<Record<string, unknown>> => attempt.status === "fulfilled") + .map((attempt) => attempt.value); + assert.ok(approvals.length >= 1, "one subject claims the pending authorization"); + assert.ok(approvals.length <= 8, "only calls for the claimed subject can recover"); + const approvedSubjects = new Set(approvals.map((approval) => approval.subject_id)); + const approvedTokens = new Set(approvals.map((approval) => approval.access_token)); + assert.equal(approvedSubjects.size, 1, "all successful callers have the same subject"); + assert.equal(approvedTokens.size, 1, "all successful callers have the same token"); + assert.equal(countOwnerTokensForClient(), 1, "mixed concurrent calls mint one owner token"); + + const row = ownerDeviceRow(started.device_code); + assert.equal(row.status, "approved"); + assert.equal(row.subject_id, [...approvedSubjects][0]); + assert.equal(row.token_id, [...approvedTokens][0]); +}); + +test("owner-device approval rejects expired rows before owner token issuance", async () => { + await setupSqliteAuth(); + const started = await startOwnerDeviceAuth(); + getDb() + .prepare("UPDATE owner_device_auth SET expires_at = ? WHERE device_code = ?") + .run(new Date(Date.now() - 1000).toISOString(), started.device_code); + + await assert.rejects( + approveOwnerDeviceAuthorization(started.user_code, "owner_local"), + (err: unknown) => err instanceof Error && "code" in err && err.code === "not_found" + ); + + assert.deepEqual(ownerDeviceRow(started.device_code), { status: "expired", subject_id: null, token_id: null }); + assert.equal(countOwnerTokensForClient(), 0, "expired approval does not mint"); +}); + +test("owner-device denial persists one rejection event and is terminal", async () => { + await setupSqliteAuth(); + const started = await startOwnerDeviceAuth(); + + await denyOwnerDeviceAuthorization(started.user_code); + + assert.deepEqual(ownerDeviceRow(started.device_code), { status: "denied", subject_id: null, token_id: null }); + assert.equal(countOwnerTokensForClient(), 0, "denial does not mint an owner token"); + assert.equal(countOwnerDeviceEvents(started.device_code, "request.rejected"), 1); + assert.equal(countOwnerDeviceEvents(started.device_code, "consent.approved"), 0); +}); + +test("owner-device approval wins a denial race without contradictory rejection", async () => { + await setupSqliteAuth(); + const started = await startOwnerDeviceAuth(); + const pause = createPause(); + const denial = denyOwnerDeviceAuthorization(started.user_code, "owner_local", { + beforeCasHook: pause.hook, + }); + await pause.paused; + const approved = await approveOwnerDeviceAuthorization(started.user_code, "owner_local"); + pause.release(); + + await assert.rejects( + denial, + (err: unknown) => err instanceof Error && "code" in err && err.code === "approval_conflict" + ); + assert.equal((await introspect(approved.access_token)).active, true); + assert.equal(countOwnerTokensForClient(), 1); + assert.equal(countOwnerDeviceEvents(started.device_code, "consent.approved"), 1); + assert.equal(countOwnerDeviceEvents(started.device_code, "request.rejected"), 0); +}); + +test("owner-device denial wins before approval and denial event rolls back on failure", async () => { + await setupSqliteAuth(); + const rollback = await startOwnerDeviceAuth(); + const faultHook: AuthorizationDecisionFaultHook = (stage) => { + if (stage === "after_event_before_commit") { + throw new Error("forced denial event rollback"); + } + }; + await assert.rejects( + denyOwnerDeviceAuthorization(rollback.user_code, "owner_local", { faultHook }), + FORCED_DENIAL_EVENT_RE + ); + assert.equal(ownerDeviceRow(rollback.device_code).status, "pending"); + assert.equal(countOwnerDeviceEvents(rollback.device_code, "request.rejected"), 0); + + const denied = await startOwnerDeviceAuth(); + await denyOwnerDeviceAuthorization(denied.user_code, "owner_local"); + await assert.rejects( + approveOwnerDeviceAuthorization(denied.user_code, "owner_local"), + (err: unknown) => err instanceof Error && "code" in err && err.code === "approval_conflict" + ); + assert.equal(countOwnerTokensForClient(), 0); + assert.equal(countOwnerDeviceEvents(denied.device_code, "request.rejected"), 1); + assert.equal(countOwnerDeviceEvents(denied.device_code, "consent.approved"), 0); +}); + +test("owner-device mixed approval and denial contention has one durable terminal outcome", async () => { + const directory = mkdtempSync(join(tmpdir(), "pdpp-owner-contention-")); + const dbPath = join(directory, "pdpp.sqlite"); + try { + setupSqliteAuth(dbPath); + const started = await startOwnerDeviceAuth(); + await Promise.allSettled( + Array.from({ length: 16 }, (_, index) => + index % 2 === 0 + ? approveOwnerDeviceAuthorization(started.user_code, "owner_local") + : denyOwnerDeviceAuthorization(started.user_code, "owner_local") + ) + ); + const row = ownerDeviceRow(started.device_code); + assert.ok(row.status === "approved" || row.status === "denied"); + assert.equal(countOwnerDeviceEvents(started.device_code, "consent.approved"), row.status === "approved" ? 1 : 0); + assert.equal(countOwnerDeviceEvents(started.device_code, "request.rejected"), row.status === "denied" ? 1 : 0); + assert.equal(countOwnerTokensForClient(), row.status === "approved" ? 1 : 0); + + closeDb(); + initDb(dbPath); + assert.deepEqual(ownerDeviceRow(started.device_code), row, "terminal decision survives close/reopen"); + assert.equal(countOwnerDeviceEvents(started.device_code, "consent.approved"), row.status === "approved" ? 1 : 0); + assert.equal(countOwnerDeviceEvents(started.device_code, "request.rejected"), row.status === "denied" ? 1 : 0); + } finally { + closeDb(); + rmSync(directory, { force: true, recursive: true }); + } +}); diff --git a/reference-implementation/test/owner-read-no-phantom-connection.test.ts b/reference-implementation/test/owner-read-no-phantom-connection.test.ts index 89b6f230e..48de61ba2 100644 --- a/reference-implementation/test/owner-read-no-phantom-connection.test.ts +++ b/reference-implementation/test/owner-read-no-phantom-connection.test.ts @@ -53,6 +53,8 @@ const manualArtifactManifest = { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", diff --git a/reference-implementation/test/package-rs-client.test.ts b/reference-implementation/test/package-rs-client.test.ts index 685cb1d01..a7c5c930f 100644 --- a/reference-implementation/test/package-rs-client.test.ts +++ b/reference-implementation/test/package-rs-client.test.ts @@ -37,7 +37,7 @@ import { createPackageRsClient as createPackageRsClientUntyped } from "../server // not a full protocol schema. interface PackageRsMember { connection_id: string; - grant?: { streams: { name: string }[] }; + grant?: { streams: { instance_ids: string[]; name: string }[] }; grant_id: string; source: { kind: string; id: string }; token: string; @@ -83,7 +83,7 @@ function createPackageRsClient(opts: Parameters<typeof createPackageRsClientUnty getRaw: async (path: string) => resultWithError(await client.getRaw(path)), patchJson: async (path: string, options: { body: Record<string, unknown> }) => resultWithError(await client.patchJson(path, options)), - postJson: async (path: string, options: { body: Record<string, unknown> }) => + postJson: async (path: string, options: Parameters<typeof client.postJson>[1]) => resultWithError(await client.postJson(path, options)), }; } @@ -118,6 +118,12 @@ function makeRouter(routes: (req: FakeFetchRequest) => Promise<Response>): FakeF function memberA(): PackageRsMember { return { connection_id: "gh_main", + grant: { + streams: [ + { instance_ids: ["gh_main"], name: "repos" }, + { instance_ids: ["gh_main"], name: "issues" }, + ], + }, grant_id: "grant_A", source: { id: "github", kind: "connector" }, token: "tok_A", @@ -126,6 +132,12 @@ function memberA(): PackageRsMember { function memberB(): PackageRsMember { return { connection_id: "slack_main", + grant: { + streams: [ + { instance_ids: ["slack_main"], name: "messages" }, + { instance_ids: ["slack_main"], name: "repos" }, + ], + }, grant_id: "grant_B", source: { id: "slack", kind: "connector" }, token: "tok_B", @@ -276,7 +288,7 @@ test("schema fan-out understands the canonical { data: { connectors: [{ streams assert.equal(body.data.granted_connections.length, 1); }); -test("schema scoped to connection_id calls only that child and strips the package selector", async () => { +test("schema scoped to connection_id calls only that child and forwards the instance selector", async () => { const calls: { token: string; path: string; query: string }[] = []; // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. const fetch = makeRouter(async (req) => { @@ -300,7 +312,7 @@ test("schema scoped to connection_id calls only that child and strips the packag assert.equal(body.data.streams.length, 1); assert.equal(body.data.streams[0]?.name, "messages"); assert.equal(body.data.streams[0]?.source?.connection_id, "slack_main"); - assert.deepEqual(calls, [{ path: "/v1/schema", query: "stream=messages", token: "tok_B" }]); + assert.deepEqual(calls, [{ path: "/v1/schema", query: "connection_id=slack_main&stream=messages", token: "tok_B" }]); }); test("schema with unknown connection_id returns not_found without fanout", async () => { @@ -670,8 +682,8 @@ test("search fan-out intersects requested streams with each child grant", async return jsonResponse(500, {}); }); const members = [ - { ...memberA(), grant: { streams: [{ name: "conversations" }] } }, - { ...memberB(), grant: { streams: [{ name: "messages" }] } }, + { ...memberA(), grant: { streams: [{ instance_ids: ["gh_main"], name: "conversations" }] } }, + { ...memberB(), grant: { streams: [{ instance_ids: ["slack_main"], name: "messages" }] } }, ]; const rs = createPackageRsClient({ fetch, members, providerUrl: PROVIDER }); @@ -709,8 +721,8 @@ test("search fan-out skips children with no requested streams in their grant", a return jsonResponse(500, {}); }); const members = [ - { ...memberA(), grant: { streams: [{ name: "conversations" }] } }, - { ...memberB(), grant: { streams: [{ name: "messages" }] } }, + { ...memberA(), grant: { streams: [{ instance_ids: ["gh_main"], name: "conversations" }] } }, + { ...memberB(), grant: { streams: [{ instance_ids: ["slack_main"], name: "messages" }] } }, ]; const rs = createPackageRsClient({ fetch, members, providerUrl: PROVIDER }); @@ -781,6 +793,172 @@ test("query_records with connection_id routes to one child only", async () => { assert.equal(bCalled, 0); }); +test("package routing derives every authorized instance from the relevant child grant", async () => { + const tokensSeen: string[] = []; + // biome-ignore lint/suspicious/useAwait: localized test router preserves the Promise-based fetch contract. + const fetch = makeRouter(async (req) => { + tokensSeen.push(req.token); + return jsonResponse(200, { data: [] }); + }); + const multiInstanceMember = { + ...memberA(), + connection_id: "stale-display-only", + grant: { + streams: [ + { instance_ids: ["gh_main", "gh_work"], name: "repos" }, + { instance_ids: ["gh_messages"], name: "messages" }, + ], + }, + }; + const rs = createPackageRsClient({ fetch, members: [multiInstanceMember, memberB()], providerUrl: PROVIDER }); + + const secondInstance = await rs.getJson("/v1/streams/repos/records", { + query: { connection_id: "gh_work" }, + }); + assert.equal(secondInstance.ok, true); + assert.deepEqual(tokensSeen, ["tok_A"]); + + tokensSeen.length = 0; + const wrongStream = await rs.getJson("/v1/streams/messages/records", { + query: { connection_id: "gh_main" }, + }); + assert.equal(wrongStream.ok, false); + assert.equal(wrongStream.status, 404); + assert.deepEqual(tokensSeen, []); + + const forgedMetadata = await rs.getJson("/v1/streams/repos/records", { + query: { connection_id: "stale-display-only" }, + }); + assert.equal(forgedMetadata.ok, false); + assert.equal(forgedMetadata.status, 404); + assert.deepEqual(tokensSeen, []); +}); + +test("package routing treats instance handles as source- and stream-scoped", async () => { + const calls: Array<{ path: string; query: string; token: string }> = []; + const observedCalls = () => calls; + // biome-ignore lint/suspicious/useAwait: localized test router preserves the Promise-based fetch contract. + const fetch = makeRouter(async (req) => { + calls.push({ path: req.path, query: req.query.toString(), token: req.token }); + if (req.path === "/v1/schema") { + return jsonResponse(200, { data: { connectors: [], object: "schema" } }); + } + if (req.path === "/v1/streams") { + return jsonResponse(200, { data: [] }); + } + if (req.path === "/v1/search") { + return jsonResponse(200, { data: [], object: "list" }); + } + if (req.path === "/v1/event-subscriptions") { + return jsonResponse(201, { subscription_id: "sub_shared" }); + } + return jsonResponse(200, { data: [] }); + }); + const members = [ + { + ...memberA(), + connection_id: "display-a", + grant: { streams: [{ instance_ids: ["shared"], name: "repos" }] }, + }, + { + ...memberB(), + connection_id: "display-b", + grant: { streams: [{ instance_ids: ["shared"], name: "repos" }] }, + }, + ]; + const rs = createPackageRsClient({ fetch, members, providerUrl: PROVIDER }); + + const ambiguous = await rs.getJson("/v1/streams/repos/records", { + query: { connection_id: "shared" }, + }); + assert.equal(ambiguous.ok, false); + assert.equal(ambiguous.status, 409); + assert.equal((ambiguous.error as PackageRsErrorEnvelope).code, "ambiguous_connection"); + assert.equal((ambiguous.error as PackageRsErrorEnvelope).retry_with, "source_id"); + assert.deepEqual(calls, []); + + await Promise.all( + ( + [ + ["/v1/schema", { connection_id: "shared", stream: "repos" }], + ["/v1/streams", { connection_id: "shared" }], + ["/v1/search", { connection_id: "shared", q: "term", streams: ["repos"] }], + ] as const + ).map(async ([path, query]) => { + const response = await rs.getJson(path, { query }); + assert.equal(response.ok, false, path); + assert.equal(response.status, 409, path); + assert.equal((response.error as PackageRsErrorEnvelope).retry_with, "source_id", path); + assert.deepEqual(calls, [], path); + }) + ); + const ambiguousEvent = await rs.postJson("/v1/event-subscriptions", { + body: { callback_url: "https://x/y", connection_id: "shared" }, + }); + assert.equal(ambiguousEvent.ok, false); + assert.equal(ambiguousEvent.status, 409); + assert.equal((ambiguousEvent.error as PackageRsErrorEnvelope).retry_with, "source_id"); + assert.deepEqual(calls, []); + + const selected = await rs.getJson("/v1/streams/repos/records", { + query: { connection_id: "shared", source_id: "github" }, + }); + assert.equal(selected.ok, true); + assert.deepEqual( + (calls as Array<{ token: string }>).map((call) => call.token), + ["tok_A"] + ); + assert.equal( + (calls as Array<{ query: string }>)[0]?.query, + "connection_id=shared", + "package-only source selector is not forwarded" + ); + + calls.length = 0; + const mismatch = await rs.getJson("/v1/streams/repos/records", { + query: { connection_id: "shared", source_id: "missing" }, + }); + assert.equal(mismatch.ok, false); + assert.equal(mismatch.status, 404); + assert.deepEqual(calls, []); + + const assertSelectedSurface = async (path: string, query: Record<string, string | string[]>) => { + calls.length = 0; + const response = await rs.getJson(path, { query }); + assert.equal(response.ok, true, `${path}: ${JSON.stringify(response.error)}`); + assert.deepEqual( + (calls as Array<{ token: string }>).map((call) => call.token), + ["tok_A"], + path + ); + const [firstCall] = observedCalls(); + assert.ok(firstCall); + assert.equal(firstCall.query.includes("source_id"), false, path); + }; + await assertSelectedSurface("/v1/schema", { + connection_id: "shared", + source_id: "github", + stream: "repos", + }); + await assertSelectedSurface("/v1/streams", { connection_id: "shared", source_id: "github" }); + await assertSelectedSurface("/v1/search", { + connection_id: "shared", + q: "term", + source_id: "github", + streams: ["repos"], + }); + + calls.length = 0; + const event = await rs.postJson("/v1/event-subscriptions", { + body: { callback_url: "https://x/y", connection_id: "shared", source_id: "github" }, + }); + assert.equal(event.ok, true); + assert.deepEqual( + (calls as Array<{ token: string }>).map((call) => call.token), + ["tok_A"] + ); +}); + test("query_records ambiguity is fast and does not probe child health", async () => { // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. const fetch = makeRouter(async (req) => { diff --git a/reference-implementation/test/pdpp.test.ts b/reference-implementation/test/pdpp.test.ts index 9750b735a..51cfc62c1 100644 --- a/reference-implementation/test/pdpp.test.ts +++ b/reference-implementation/test/pdpp.test.ts @@ -12,6 +12,7 @@ import { createCimdDocument, issueToken, parsePendingConsentRequestUri, + registerConnector as registerConnectorCatalog, revokeCimdClientAccessForSecurityMetadataChange, } from "../server/auth.ts"; import { canonicalConnectorKey } from "../server/connector-key.ts"; @@ -23,6 +24,8 @@ import { admitOwnerRunConnection, makeDefaultAccountConnectorInstanceId, } from "../server/stores/connector-instance-store.ts"; +import { introspectionHeaders } from "./helpers/introspection.ts"; +import { TEST_INTROSPECTION_SERVER_OPTS } from "./helpers/introspection-test-credentials.ts"; // Real ingest resolves the acting owner subject from the request's bearer // token (`getOwnerTokenSubjectId` in server/index.ts), independent of @@ -64,21 +67,20 @@ const REGEXP_11 = /authorization_details/; const REGEXP_12 = /requires client_id/; const REGEXP_13 = /^urn:pdpp:pending-consent:/; const REGEXP_14 = /Unsupported request fields: code_challenge, redirect_uri, response_type/; -const REGEXP_15 = /Unsupported authorization_details type/; -const REGEXP_16 = /access_mode must be "single_use" or "continuous"/; -const REGEXP_17 = /streams must be a non-empty array/; -const REGEXP_18 = /Unsupported authorization_details fields: locations/; -const REGEXP_19 = /Unsupported stream selection fields on 'top_artists': expand/; -const REGEXP_20 = /Unknown source/; +const REGEXP_15 = /type must be equal to constant/; +const REGEXP_16 = /access_mode must be equal to one of the allowed values/; +const REGEXP_17 = /streams must NOT have fewer than 1 items/; +const REGEXP_18 = /must NOT have additional properties/; +const REGEXP_19 = /streams\/0 must NOT have additional properties/; const REGEXP_21 = /Unknown stream: not_a_real_stream/; const REGEXP_22 = /Unknown view 'not_a_real_view' on stream 'top_artists'/; -const REGEXP_23 = /view and fields are mutually exclusive/; +const REGEXP_23 = /streams\/0 must NOT be valid/; const REGEXP_24 = /Unknown fields on stream 'top_artists': not_a_real_field/; -const REGEXP_25 = /fields must be a non-empty array of field names/; +const REGEXP_25 = /streams\/0\/fields must NOT have fewer than 1 items/; const REGEXP_26 = /Unknown client_id/; const REGEXP_27 = /malformed or no longer valid/; const REGEXP_28 = /malformed or no longer valid/; -const REGEXP_29 = /Registered Longview/; +const REGEXP_29 = /Longview wants access to your data/; const REGEXP_30 = /Forged Display Name/; const REGEXP_31 = /Updated Longview/; const REGEXP_32 = /Persisted Forgery/; @@ -87,7 +89,6 @@ const REGEXP_34 = /source_binding must include only kind and id/; const REGEXP_35 = /source_binding must include only kind and id/; const REGEXP_36 = /source_binding must include only kind and id/; const REGEXP_37 = /source_binding must include only kind and id/; -const REGEXP_38 = /Unknown client_id/; const REGEXP_39 = /Unknown client_id/; const REGEXP_40 = /Unknown client_id/; const REGEXP_41 = /Access Denied/; @@ -130,42 +131,29 @@ const REGEXP_77 = /Unsupported pending request fields: redirect_uri/; const REGEXP_78 = /Unsupported pending request fields: redirect_uri/; const REGEXP_79 = /Unsupported pending stream selection fields on 'top_artists': expand/; const REGEXP_80 = /Unsupported pending stream selection fields on 'top_artists': expand/; -const REGEXP_81 = /Pending consent request manifest_version '999\.0\.0' does not match current manifest version/; -const REGEXP_82 = /Pending consent request manifest_version '999\.0\.0' does not match current manifest version/; -const REGEXP_83 = /Pending consent request manifest_version '999\.0\.0' does not match current manifest version/; -const REGEXP_84 = /Pending consent request manifest_version '999\.0\.0' does not match current manifest version/; -const REGEXP_85 = /Pending consent request manifest_version '999\.0\.0' does not match current manifest version/; const REGEXP_86 = /Access Denied/; -const REGEXP_87 = /source.*provider_native/; -const REGEXP_88 = /provider_native/; +const REGEXP_87 = /source\/id must match format "uri"/; const REGEXP_89 = /Grant is malformed or no longer valid/; -const REGEXP_90 = /Grant is malformed or no longer valid/; const REGEXP_91 = /Grant is malformed or no longer valid/; const REGEXP_92 = /Grant is malformed or no longer valid/; const REGEXP_93 = /Grant is malformed or no longer valid/; -const REGEXP_94 = /Unknown connector: missing_spotify_connector/; -const REGEXP_95 = /Unknown connector: missing_spotify_connector/; +const REGEXP_94 = /Grant is malformed or no longer valid/; +const REGEXP_95 = /Token introspection failed closed/; const REGEXP_96 = /Grant is malformed or no longer valid/; const REGEXP_97 = /Grant is malformed or no longer valid/; -const REGEXP_98 = /Grant is malformed or no longer valid/; -const REGEXP_99 = /Grant is malformed or no longer valid/; -const REGEXP_100 = /Grant is malformed or no longer valid/; +const REGEXP_98 = /Stream 'missing_stream' is not declared by the current manifest/; const REGEXP_101 = /Grant is malformed or no longer valid/; -const REGEXP_102 = /Grant is malformed or no longer valid/; -const REGEXP_103 = /Grant is malformed or no longer valid/; -const REGEXP_104 = /Grant is malformed or no longer valid/; const REGEXP_105 = /Grant is malformed or no longer valid/; -const REGEXP_106 = /Unknown source/; -const REGEXP_107 = /source: \{ kind/; +const REGEXP_106 = /source\/id must match format "uri"/; +const REGEXP_107 = /Selection request is invalid: \/ must NOT have additional properties/; const REGEXP_108 = /source_binding is required/; const REGEXP_109 = /source_binding is required/; const REGEXP_110 = /source_binding must include only kind and id/; const REGEXP_111 = /source_binding must include only kind and id/; -const REGEXP_112 = /source_binding\.id must match storage_binding\.connector_id/; -const REGEXP_113 = /source_binding\.id must match storage_binding\.connector_id/; -const REGEXP_114 = /provider_native/; -const REGEXP_115 = /Unknown connector: missing_spotify_connector/; -const REGEXP_116 = /Unknown connector: missing_spotify_connector/; +const REGEXP_112 = /declaration snapshot source does not match the request/; +const REGEXP_113 = /declaration snapshot source does not match the request/; +const REGEXP_115 = /Unknown source: missing_spotify_connector/; +const REGEXP_116 = /Unknown source: missing_spotify_connector/; const REGEXP_117 = /connector_id must be a single non-empty string/; const REGEXP_118 = /connector_id must be a single non-empty string/; const REGEXP_119 = /connector_id must be a single non-empty string/; @@ -191,27 +179,27 @@ const REGEXP_138 = /Unknown connector: missing_spotify_connector/; const REGEXP_139 = /connector_id must be a single non-empty string/; const REGEXP_140 = /connector_id must be a single non-empty string/; const REGEXP_141 = /Connector manifest .* is malformed or no longer valid/; -const REGEXP_142 = /Stream 'recently_played' not in grant/; -const REGEXP_143 = /Stream 'recently_played' not in grant/; -const REGEXP_144 = /Stream 'recently_played' not in grant/; -const REGEXP_145 = /Stream 'recently_played' not in grant/; -const REGEXP_146 = /Stream 'saved_tracks' not in grant/; -const REGEXP_147 = /Stream 'saved_tracks' not in grant/; +const REGEXP_142 = /Token introspection failed closed/; +const REGEXP_143 = /Invalid or expired token/; +const REGEXP_144 = /Token introspection failed closed/; +const REGEXP_145 = /Invalid or expired token/; +const REGEXP_146 = /Token introspection failed closed/; +const REGEXP_147 = /Invalid or expired token/; const REGEXP_148 = /Record not found/; const REGEXP_149 = /Record not found/; const REGEXP_150 = /Record not found/; const REGEXP_151 = /Record not found/; -const REGEXP_152 = /Filter on field 'popularity' not in grant/; -const REGEXP_153 = /Filter on field 'popularity' not in grant/; -const REGEXP_154 = /View includes fields not in grant: popularity/; -const REGEXP_155 = /View includes fields not in grant: popularity/; -const REGEXP_156 = /Filter on field 'popularity' not in grant/; -const REGEXP_157 = /Filter on field 'popularity' not in grant/; +const REGEXP_154 = /Client record reads must use explicit fields/; +const REGEXP_155 = /Client record reads must use explicit fields/; +const CLIENT_FILTER_ERROR = /filter\[\.\.\.\] is not supported for client-token reads/; const REGEXP_158 = /view and fields are mutually exclusive/; const REGEXP_159 = /view and fields are mutually exclusive/; const REGEXP_160 = /Stream 'not_a_stream' not found/; const REGEXP_161 = /already been consumed/i; const REGEXP_162 = /missing_native_storage_connector/; +const REGEXP_163 = /not addressable under this grant/; +const REGEXP_164 = /Unknown source: https:\/\/northstar\.example\/pdpp/; +const REGEXP_165 = /Transient Longview/; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); @@ -225,6 +213,7 @@ if (spotifyConnectorKey === null) { throw new TypeError("canonical Spotify connector key must be present"); } const SPOTIFY_CONNECTOR_KEY = spotifyConnectorKey; +const SPOTIFY_SOURCE_ID = "https://registry.pdpp.dev/connectors/spotify"; type JsonRecord = Record<string, unknown>; @@ -337,9 +326,9 @@ interface ApprovedGrant { client_display: { name: string; uri: string | null | undefined; - }; + } | null; client_id: string; - registration_mode: string; + registration_mode: string | null | undefined; }; grant_id: string; retention: { max_duration: string } | null | undefined; @@ -384,6 +373,9 @@ interface OAuthDeviceErrorResponse { interface IntrospectionResponse { active: boolean; + authorization_details?: Array<{ + source: SourceDescriptor | null | undefined; + }>; grant?: | { source: SourceDescriptor | null | undefined; @@ -450,16 +442,23 @@ interface ConnectorManifest extends JsonRecord { } interface NativeManifest extends JsonRecord { + name: string; provider_id: string; + source_declaration: { + protocol_version: string; + streams: Array<{ + consent_time_field?: string; + name: string; + primary_key: string[]; + semantics: string; + [key: string]: unknown; + }>; + [key: string]: unknown; + }; storage_binding: { connector_id: string; }; - streams: Array<{ - consent_time_field: string; - name: string; - primary_key: string; - semantics: string; - }>; + version: string; } interface ResourceRecord extends JsonRecord { @@ -492,12 +491,12 @@ interface ResourceStreamListResponse { } interface ResourceStreamMetadataResponse extends JsonRecord { - consent_time_field: string; + consent_time_field: string | null | undefined; name: string; object: string; primary_key: string[]; schema: { properties: JsonRecord; required: string[] }; - semantics: string; + semantics: string | null | undefined; views: Array<{ id: string }>; } @@ -526,6 +525,59 @@ interface GrantRequestParams { streams: unknown; } +const DEFAULT_GRANT_FIXTURE_OWNER_SUBJECTS = ["u1", "owner_local", "employee_1"] as const; + +function sourceIdForConnectorId(connectorId: string | undefined): string | undefined { + if (connectorId === undefined || connectorId.includes("://")) { + return connectorId; + } + return `https://registry.pdpp.dev/connectors/${connectorId}`; +} + +async function seedDefaultGrantInstance( + connectorId: string, + ownerSubjectId: string, + displayName: string +): Promise<void> { + const connectorKey = canonicalConnectorKey(connectorId) ?? connectorId; + const connectorInstanceId = makeDefaultAccountConnectorInstanceId(ownerSubjectId, connectorKey); + const now = new Date().toISOString(); + await createRequestConnectorInstanceStore().upsert({ + connectorId: connectorKey, + connectorInstanceId, + createdAt: now, + displayName, + ownerSubjectId, + sourceBinding: { fixture: "pdpp-grant-omission-default-account" }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); +} + +async function seedDefaultGrantInstances(connectorId: string, displayName: string): Promise<void> { + await Promise.all( + DEFAULT_GRANT_FIXTURE_OWNER_SUBJECTS.map((ownerSubjectId) => + seedDefaultGrantInstance(connectorId, ownerSubjectId, displayName) + ) + ); +} + +async function registerNativeHarnessCatalog(nativeManifest: NativeManifest): Promise<void> { + await registerConnectorCatalog( + { + connector_id: nativeManifest.storage_binding.connector_id, + display_name: nativeManifest.name, + protocol_version: nativeManifest.source_declaration.protocol_version, + source_declaration: nativeManifest.source_declaration, + streams: nativeManifest.source_declaration.streams, + version: nativeManifest.version, + }, + { backfillRetrievalIndexes: false } + ); +} + function isJsonRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -587,10 +639,10 @@ function parseApprovedGrantResponse(value: unknown): ApprovedGrantResponse { const body = requireJsonRecord(value, "consent approval response"); const grant = requireJsonRecord(body.grant, "consent approval response.grant"); const client = requireJsonRecord(grant.client, "consent approval response.grant.client"); - const clientDisplay = requireJsonRecord( - client.client_display, - "consent approval response.grant.client.client_display" - ); + const clientDisplay = + client.client_display === null || client.client_display === undefined + ? null + : requireJsonRecord(client.client_display, "consent approval response.grant.client.client_display"); const retention = grant.retention === null || grant.retention === undefined ? null @@ -600,12 +652,15 @@ function parseApprovedGrantResponse(value: unknown): ApprovedGrantResponse { grant: { access_mode: requireString(grant.access_mode, "consent approval response.grant.access_mode"), client: { - client_display: { - name: requireString(clientDisplay.name, "consent approval response.grant.client.client_display.name"), - uri: optionalString(clientDisplay.uri, "consent approval response.grant.client.client_display.uri"), - }, + client_display: + clientDisplay === null + ? null + : { + name: requireString(clientDisplay.name, "consent approval response.grant.client.client_display.name"), + uri: optionalString(clientDisplay.uri, "consent approval response.grant.client.client_display.uri"), + }, client_id: requireString(client.client_id, "consent approval response.grant.client.client_id"), - registration_mode: requireString( + registration_mode: optionalString( client.registration_mode, "consent approval response.grant.client.registration_mode" ), @@ -628,6 +683,14 @@ function parseApprovedGrantResponse(value: unknown): ApprovedGrantResponse { }; } +function readPersistedGrantJson(grantId: string): JsonRecord { + const row = getDb().prepare("SELECT grant_json FROM grants WHERE grant_id = ?").get(grantId) as + | { grant_json?: unknown } + | undefined; + const grantJson = requireString(row?.grant_json, "persisted grant.grant_json"); + return requireJsonRecord(JSON.parse(grantJson), "persisted grant.grant_json"); +} + function parseDeviceAuthorizationResponse(value: unknown): { device_code: string; user_code: string } { const body = requireJsonRecord(value, "device authorization response"); return { @@ -680,12 +743,24 @@ function parseOAuthDeviceErrorResponse(value: unknown): OAuthDeviceErrorResponse function parseIntrospectionResponse(value: unknown): IntrospectionResponse { const body = requireJsonRecord(value, "introspection response"); + const authorizationDetails = + body.authorization_details === undefined + ? undefined + : requireJsonRecordArray(body.authorization_details, "introspection response.authorization_details").map( + (detail, index) => ({ + source: optionalSourceDescriptor( + detail.source, + `introspection response.authorization_details[${index}].source` + ), + }) + ); const grant = body.grant === undefined || body.grant === null ? undefined : requireJsonRecord(body.grant, "introspection response.grant"); return { active: requireBoolean(body.active, "introspection response.active"), + ...(authorizationDetails === undefined ? {} : { authorization_details: authorizationDetails }), inactive_reason: optionalString(body.inactive_reason, "introspection response.inactive_reason"), ...(grant === undefined ? {} @@ -843,7 +918,7 @@ function parseResourceStreamMetadataResponse(value: unknown): ResourceStreamMeta const views = requireJsonRecordArray(body.views, "resource stream-metadata response.views"); return { ...body, - consent_time_field: requireString(body.consent_time_field, "resource stream-metadata response.consent_time_field"), + consent_time_field: optionalString(body.consent_time_field, "resource stream-metadata response.consent_time_field"), name: requireString(body.name, "resource stream-metadata response.name"), object: requireString(body.object, "resource stream-metadata response.object"), primary_key: (() => { @@ -862,6 +937,9 @@ function parseResourceStreamMetadataResponse(value: unknown): ResourceStreamMeta required: (() => { // biome-ignore lint/style/useDestructuring: Indexed access expresses the protocol field position under test. const required = requireJsonRecord(body.schema, "resource stream-metadata response.schema").required; + if (required === undefined) { + return []; + } if (!Array.isArray(required)) { throw new TypeError("resource stream-metadata response.schema.required must be an array"); } @@ -870,7 +948,7 @@ function parseResourceStreamMetadataResponse(value: unknown): ResourceStreamMeta ); })(), }, - semantics: requireString(body.semantics, "resource stream-metadata response.semantics"), + semantics: optionalString(body.semantics, "resource stream-metadata response.semantics"), views: views.map((view, index) => ({ id: requireString(view.id, `resource stream-metadata response.views[${index}].id`), })), @@ -1012,6 +1090,7 @@ async function withHarness(fn: (harness: Harness) => Promise<void>): Promise<voi dynamicClientRegistrationInitialAccessTokens: [TEST_DCR_INITIAL_ACCESS_TOKEN], quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -1025,6 +1104,7 @@ async function withHarness(fn: (harness: Harness) => Promise<void>): Promise<voi headers: { "Content-Type": "application/json" }, method: "POST", }); + await seedDefaultGrantInstances(spotifyManifest.connector_id, "Spotify"); await fn({ asUrl, rsUrl, spotifyManifest }); } finally { @@ -1042,11 +1122,14 @@ async function withNativeHarness(fn: (harness: NativeHarness) => Promise<void>): nativeManifest, quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; try { + await registerNativeHarnessCatalog(nativeManifest); + await seedDefaultGrantInstances(nativeManifest.storage_binding.connector_id, nativeManifest.name); await fn({ asUrl, nativeManifest, rsUrl }); } finally { await closeServer(server); @@ -1075,7 +1158,7 @@ async function startGrantRequestRaw(asUrl: string, params: GrantRequestParams) { params.source || (params.provider_id ? { id: params.provider_id, kind: "provider_native" } - : { id: params.connector_id, kind: "connector" }), + : { id: sourceIdForConnectorId(params.connector_id), kind: "connector" }), streams: params.streams, type: "https://pdpp.dev/data-access", }, @@ -1104,11 +1187,29 @@ async function startGrantRequestRejection( return { ...response, body: parseErrorResponse(response.body) }; } -// biome-ignore lint/suspicious/useAwait: Async callback preserves the dependency contract and rejection timing. async function approveGrantRequest(asUrl: string, requestUri: string, subjectId: string, extra: JsonRecord = {}) { - return fetchJson(`${asUrl}/consent/approve`, { + const review = await fetchJson(`${asUrl}/consent/review`, { body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId, ...extra }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + if (review.status !== 200) { + return review; + } + const reviewBody = requireJsonRecord(review.body, "consent review response"); + requireJsonRecord(reviewBody.approval_review, "consent review response.approval_review"); + const reviewRevision = requireString( + reviewBody.approval_review_revision, + "consent review response.approval_review_revision" + ); + const canonicalRequestUri = requireString(reviewBody.request_uri, "consent review response.request_uri"); + if (canonicalRequestUri !== requestUri) { + throw new Error("consent review returned a different request_uri"); + } + const finalOptions: JsonRecord = { approval_review_revision: reviewRevision }; + return fetchJson(`${asUrl}/consent/approve`, { + body: JSON.stringify({ request_uri: canonicalRequestUri, ...finalOptions }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); } @@ -1151,7 +1252,7 @@ async function startOwnerDeviceAuthorization( async function introspectToken(asUrl: string, token: string): Promise<FetchJsonResult<IntrospectionResponse>> { const response = await fetchJson(`${asUrl}/introspect`, { body: JSON.stringify({ token }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); return { ...response, body: parseIntrospectionResponse(response.body) }; @@ -1160,7 +1261,7 @@ async function introspectToken(asUrl: string, token: string): Promise<FetchJsonR async function introspectFormToken(asUrl: string, token: string): Promise<FetchJsonResult<IntrospectionResponse>> { const response = await fetchJson(`${asUrl}/introspect`, { body: new URLSearchParams({ token }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: introspectionHeaders("application/x-www-form-urlencoded"), method: "POST", }); return { ...response, body: parseIntrospectionResponse(response.body) }; @@ -1341,7 +1442,7 @@ async function seedNorthstar(nativeManifest: NativeManifest): Promise<void> { { data: { currency: "USD", - employee_id: "emp_123", + employee_id: "employee_1", employer: "Northstar HR", gross_pay: 5400, issued_at: "2026-04-16T12:00:00Z", @@ -1357,7 +1458,7 @@ async function seedNorthstar(nativeManifest: NativeManifest): Promise<void> { { data: { currency: "USD", - employee_id: "emp_123", + employee_id: "employee_1", employer: "Northstar HR", grant_id: "eq_2026_01_01", grant_type: "RSU", @@ -1377,7 +1478,7 @@ async function seedNorthstar(nativeManifest: NativeManifest): Promise<void> { currency: "USD", effective_date: "2026-01-01", employee_cost_monthly: 280, - employee_id: "emp_123", + employee_id: "employee_1", employer: "Northstar HR", enrollment_id: "ben_medical_2026", plan_name: "Northstar PPO", @@ -1388,9 +1489,16 @@ async function seedNorthstar(nativeManifest: NativeManifest): Promise<void> { }, ]; + const storageTarget = { + connector_id: nativeManifest.storage_binding.connector_id, + connector_instance_id: makeDefaultAccountConnectorInstanceId( + "employee_1", + canonicalConnectorKey(nativeManifest.storage_binding.connector_id) ?? nativeManifest.storage_binding.connector_id + ), + }; for (const record of records) { // biome-ignore lint/performance/noAwaitInLoops: Sequential test setup and assertion order is intentional. - await ingestRecord(nativeManifest.storage_binding.connector_id, record); + await ingestRecord(storageTarget, record); } } @@ -1448,29 +1556,36 @@ test("PDPP reference implementation integration", async (t) => { const { dbPath, cleanup } = createTempDbPath(); const spotifyManifest = JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8")); - let server = await startServer({ asPort: 0, dbPath, quiet: true, rsPort: 0 }); + let server = await startServer({ asPort: 0, dbPath, quiet: true, rsPort: 0, ...TEST_INTROSPECTION_SERVER_OPTS }); const asUrl = `http://localhost:${server.asPort}`; try { await fetchJson(`${asUrl}/connectors`, { body: JSON.stringify(spotifyManifest), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); + await seedDefaultGrantInstance(spotifyManifest.connector_id, "u1", "Spotify"); const { body: initiate } = await startGrantRequest(asUrl, { access_mode: "continuous", client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); assert.ok(initiate.request_uri); await closeServer(server); - server = await startServer({ asPort: server.asPort, dbPath, quiet: true, rsPort: server.rsPort }); + server = await startServer({ + asPort: server.asPort, + dbPath, + quiet: true, + rsPort: server.rsPort, + ...TEST_INTROSPECTION_SERVER_OPTS, + }); const consentResp = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(initiate.request_uri)}`); assert.equal(consentResp.status, 200); @@ -1493,7 +1608,7 @@ test("PDPP reference implementation integration", async (t) => { await t.test("expired pending consent is rejected consistently across display and approve paths", async () => { const { dbPath, cleanup } = createTempDbPath(); const spotifyManifest = JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8")); - const server = await startServer({ asPort: 0, dbPath, quiet: true, rsPort: 0 }); + const server = await startServer({ asPort: 0, dbPath, quiet: true, rsPort: 0, ...TEST_INTROSPECTION_SERVER_OPTS }); const asUrl = `http://localhost:${server.asPort}`; try { @@ -1502,17 +1617,29 @@ test("PDPP reference implementation integration", async (t) => { headers: { "Content-Type": "application/json" }, method: "POST", }); + await seedDefaultGrantInstance(spotifyManifest.connector_id, "u1", "Spotify"); const { body: initiate } = await startGrantRequest(asUrl, { access_mode: "continuous", client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); const deviceCode = parsePendingConsentRequestUri(initiate.request_uri); + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: "u1" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewBody = requireJsonRecord(await reviewResp.json(), "consent review response"); + requireJsonRecord(reviewBody.approval_review, "consent review response.approval_review"); + const reviewRevision = requireString( + reviewBody.approval_review_revision, + "consent review response.approval_review_revision" + ); getDb() .prepare(` UPDATE pending_consents @@ -1525,11 +1652,11 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(consentResp.status, 404); const approveResp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: "u1" }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approval_review_revision: reviewRevision, request_uri: initiate.request_uri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); - assert.equal(approveResp.status, 404); + assert.equal(approveResp.status, 409); } finally { await closeServer(server); cleanup(); @@ -1549,7 +1676,7 @@ test("PDPP reference implementation integration", async (t) => { max_duration: "P30D", on_expiry: "delete", }, - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], type: "https://pdpp.dev/data-access", }, @@ -1578,18 +1705,19 @@ test("PDPP reference implementation integration", async (t) => { const { body: approved } = await approveGrantSuccess(asUrl, initiate.request_uri, "u1"); assert.equal(approved.grant.client.client_id, "longview"); - assert.equal(approved.grant.client.client_display.name, "Longview"); + assert.equal(approved.grant.client.client_display, null); + assert.equal(approved.grant.client.registration_mode, undefined); // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. assert.equal(approved.grant.source?.kind, "connector"); // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. - assert.equal(approved.grant.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(approved.grant.source?.id, SPOTIFY_SOURCE_ID); assert.equal(approved.grant.access_mode, "continuous"); assert.equal(requireRetention(approved.grant.retention).max_duration, "P30D"); // biome-ignore lint/style/useDestructuring: Indexed access expresses the protocol field position under test. const approvedStream = approved.grant.streams[0]; assert.ok(approvedStream, "approval should include the requested stream"); assert.equal(approvedStream.name, "top_artists"); - assert.equal(approvedStream.view, "basic"); + assert.equal(approvedStream.view, undefined); assert.ok(approved.token); const grantRows = getDb() @@ -1618,7 +1746,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); @@ -1679,6 +1807,7 @@ test("PDPP reference implementation integration", async (t) => { dynamicClientRegistrationInitialAccessTokens: [TEST_DCR_INITIAL_ACCESS_TOKEN], quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -1690,6 +1819,7 @@ test("PDPP reference implementation integration", async (t) => { headers: { "Content-Type": "application/json" }, method: "POST", }); + await seedDefaultGrantInstance(spotifyManifest.connector_id, "u1", "Spotify"); await seedSpotify(rsUrl, spotifyManifest, await issueOwnerToken(asUrl, "u1")); const documentId = await createCimdDocument({ @@ -1702,7 +1832,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: clientId, purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Read top artists through a CIMD-identified local MCP client.", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); @@ -1718,8 +1848,8 @@ test("PDPP reference implementation integration", async (t) => { const { body: approved } = await approveGrantSuccess(asUrl, initiate.request_uri, "u1"); assert.equal(approved.grant.client.client_id, clientId); - assert.equal(approved.grant.client.client_display.name, "Codex"); - assert.equal(approved.grant.client.registration_mode, "client_id_metadata_document"); + assert.equal(approved.grant.client.client_display, null); + assert.equal(approved.grant.client.registration_mode, undefined); assert.ok(approved.token); const clientRecordsResp = await fetch(`${rsUrl}/v1/streams/top_artists/records?limit=1`, { @@ -1751,10 +1881,9 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(tools.status, 200); assert.deepEqual( - // biome-ignore lint/suspicious/useArraySortCompare: Fixture values use the runtime default sort semantics under test. parseMcpToolsResponse(tools.body) .result.tools.map((tool) => tool.name) - .sort(), + .sort((left, right) => left.localeCompare(right)), ["aggregate", "fetch", "query_records", "read_record_field", "schema", "search"] ); @@ -1774,7 +1903,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: badClientId, purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "This request must fail before consent because the CIMD document is missing.", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); assert.equal(failed.status, 400); @@ -1800,7 +1929,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); @@ -1828,7 +1957,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(rejectedEvent.request_id, revokeRequestId); assert.equal(rejectedEvent.trace_id, revokeTraceId); assert.equal(rejectedEvent.data?.source?.kind, "connector"); - assert.equal(rejectedEvent.data?.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(rejectedEvent.data?.source?.id, SPOTIFY_SOURCE_ID); assert.ok( !("connector_id" in (rejectedEvent.data || {})), "polyfill revoke rejection should use a source descriptor instead of a raw connector_id field" @@ -1860,7 +1989,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], type: "https://pdpp.dev/data-access", }, @@ -1880,14 +2009,14 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], type: "https://pdpp.dev/data-access", }, { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "saved_tracks" }], type: "https://pdpp.dev/data-access", }, @@ -1912,7 +2041,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(multiStored.request_kind, "pdpp_selection_request_batch"); assert.deepEqual( multiStored.entries.map((entry) => entry.source_binding.id), - [SPOTIFY_CONNECTOR_KEY, SPOTIFY_CONNECTOR_KEY] + [SPOTIFY_SOURCE_ID, SPOTIFY_SOURCE_ID] ); const unsupportedRequestFieldsResp = await fetch(`${asUrl}/oauth/par`, { @@ -1921,7 +2050,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], type: "https://pdpp.dev/data-access", }, @@ -1957,7 +2086,7 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(badTypeResp.status, 400); const badTypeBody = parseErrorResponse(await badTypeResp.json()); - assert.equal(badTypeBody.error.code, "invalid_request"); + assert.equal(badTypeBody.error.code, "invalid_authorization_details"); assert.match(badTypeBody.error.message, REGEXP_15); const unsupportedAccessModeResp = await fetch(`${asUrl}/oauth/par`, { @@ -1966,7 +2095,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "time_bounded", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], type: "https://pdpp.dev/data-access", }, @@ -1978,7 +2107,7 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(unsupportedAccessModeResp.status, 400); const unsupportedAccessModeBody = parseErrorResponse(await unsupportedAccessModeResp.json()); - assert.equal(unsupportedAccessModeBody.error.code, "invalid_request"); + assert.equal(unsupportedAccessModeBody.error.code, "invalid_authorization_details"); assert.match(unsupportedAccessModeBody.error.message, REGEXP_16); const emptyStreamsResp = await fetch(`${asUrl}/oauth/par`, { @@ -1987,7 +2116,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [], type: "https://pdpp.dev/data-access", }, @@ -1999,7 +2128,7 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(emptyStreamsResp.status, 400); const emptyStreamsBody = parseErrorResponse(await emptyStreamsResp.json()); - assert.equal(emptyStreamsBody.error.code, "invalid_request"); + assert.equal(emptyStreamsBody.error.code, "invalid_authorization_details"); assert.match(emptyStreamsBody.error.message, REGEXP_17); const unsupportedAuthorizationDetailFieldsResp = await fetch(`${asUrl}/oauth/par`, { @@ -2009,7 +2138,7 @@ test("PDPP reference implementation integration", async (t) => { access_mode: "continuous", locations: ["https://rs.pdpp.example"], purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ expand: ["albums"], name: "top_artists" }], type: "https://pdpp.dev/data-access", }, @@ -2023,7 +2152,7 @@ test("PDPP reference implementation integration", async (t) => { const unsupportedAuthorizationDetailFieldsBody = parseErrorResponse( await unsupportedAuthorizationDetailFieldsResp.json() ); - assert.equal(unsupportedAuthorizationDetailFieldsBody.error.code, "invalid_request"); + assert.equal(unsupportedAuthorizationDetailFieldsBody.error.code, "invalid_authorization_details"); assert.match(unsupportedAuthorizationDetailFieldsBody.error.message, REGEXP_18); const unsupportedStreamSelectionFieldsResp = await fetch(`${asUrl}/oauth/par`, { @@ -2032,7 +2161,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ expand: ["albums"], name: "top_artists" }], type: "https://pdpp.dev/data-access", }, @@ -2046,7 +2175,7 @@ test("PDPP reference implementation integration", async (t) => { const unsupportedStreamSelectionFieldsBody = parseErrorResponse( await unsupportedStreamSelectionFieldsResp.json() ); - assert.equal(unsupportedStreamSelectionFieldsBody.error.code, "invalid_request"); + assert.equal(unsupportedStreamSelectionFieldsBody.error.code, "invalid_authorization_details"); assert.match(unsupportedStreamSelectionFieldsBody.error.message, REGEXP_19); const unknownConnectorResp = await fetch(`${asUrl}/oauth/par`, { @@ -2067,8 +2196,8 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(unknownConnectorResp.status, 400); const unknownConnectorBody = parseErrorResponse(await unknownConnectorResp.json()); - assert.equal(unknownConnectorBody.error.code, "invalid_request"); - assert.match(unknownConnectorBody.error.message, REGEXP_20); + assert.equal(unknownConnectorBody.error.code, "invalid_authorization_details"); + assert.match(unknownConnectorBody.error.message, REGEXP_87); const unknownStreamResp = await fetch(`${asUrl}/oauth/par`, { body: JSON.stringify({ @@ -2076,7 +2205,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "not_a_real_stream" }], type: "https://pdpp.dev/data-access", }, @@ -2088,7 +2217,7 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(unknownStreamResp.status, 400); const unknownStreamBody = parseErrorResponse(await unknownStreamResp.json()); - assert.equal(unknownStreamBody.error.code, "invalid_request"); + assert.equal(unknownStreamBody.error.code, "invalid_authorization_details"); assert.match(unknownStreamBody.error.message, REGEXP_21); const unknownViewResp = await fetch(`${asUrl}/oauth/par`, { @@ -2097,7 +2226,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "not_a_real_view" }], type: "https://pdpp.dev/data-access", }, @@ -2109,7 +2238,7 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(unknownViewResp.status, 400); const unknownViewBody = parseErrorResponse(await unknownViewResp.json()); - assert.equal(unknownViewBody.error.code, "invalid_request"); + assert.equal(unknownViewBody.error.code, "invalid_authorization_details"); assert.match(unknownViewBody.error.message, REGEXP_22); const contradictorySelectionResp = await fetch(`${asUrl}/oauth/par`, { @@ -2118,7 +2247,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ fields: ["id"], name: "top_artists", view: "basic" }], type: "https://pdpp.dev/data-access", }, @@ -2130,7 +2259,7 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(contradictorySelectionResp.status, 400); const contradictorySelectionBody = parseErrorResponse(await contradictorySelectionResp.json()); - assert.equal(contradictorySelectionBody.error.code, "invalid_request"); + assert.equal(contradictorySelectionBody.error.code, "invalid_authorization_details"); assert.match(contradictorySelectionBody.error.message, REGEXP_23); const unknownFieldsResp = await fetch(`${asUrl}/oauth/par`, { @@ -2139,7 +2268,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ fields: ["id", "not_a_real_field"], name: "top_artists" }], type: "https://pdpp.dev/data-access", }, @@ -2151,7 +2280,7 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(unknownFieldsResp.status, 400); const unknownFieldsBody = parseErrorResponse(await unknownFieldsResp.json()); - assert.equal(unknownFieldsBody.error.code, "invalid_request"); + assert.equal(unknownFieldsBody.error.code, "invalid_authorization_details"); assert.match(unknownFieldsBody.error.message, REGEXP_24); const malformedFieldsResp = await fetch(`${asUrl}/oauth/par`, { @@ -2160,7 +2289,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ fields: [], name: "top_artists" }], type: "https://pdpp.dev/data-access", }, @@ -2172,7 +2301,7 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(malformedFieldsResp.status, 400); const malformedFieldsBody = parseErrorResponse(await malformedFieldsResp.json()); - assert.equal(malformedFieldsBody.error.code, "invalid_request"); + assert.equal(malformedFieldsBody.error.code, "invalid_authorization_details"); assert.match(malformedFieldsBody.error.message, REGEXP_25); }); }); @@ -2185,7 +2314,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], type: "https://pdpp.dev/data-access", }, @@ -2219,7 +2348,7 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], type: "https://pdpp.dev/data-access", }, @@ -2255,14 +2384,14 @@ test("PDPP reference implementation integration", async (t) => { { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], type: "https://pdpp.dev/data-access", }, ], client_id: registration.body.client_id, }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); @@ -2286,7 +2415,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(rejectedEvent.data?.error?.code, "invalid_client"); assert.match(rejectedEvent.data?.error?.message || "", REGEXP_28); assert.equal(rejectedEvent.data?.source?.kind, "connector"); - assert.equal(rejectedEvent.data?.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(rejectedEvent.data?.source?.id, SPOTIFY_SOURCE_ID); assert.ok(!("connector_id" in (rejectedEvent.data || {}))); }); } @@ -2303,7 +2432,7 @@ test("PDPP reference implementation integration", async (t) => { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], type: "https://pdpp.dev/data-access", }, @@ -2334,7 +2463,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(submittedEvent.client_id, "longview"); assert.equal(submittedEvent.status, "succeeded"); assert.equal(submittedEvent.data?.source?.kind, "connector"); - assert.equal(submittedEvent.data?.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(submittedEvent.data?.source?.id, SPOTIFY_SOURCE_ID); assert.ok(!("connector_id" in (submittedEvent.data || {}))); }); } @@ -2382,7 +2511,7 @@ test("PDPP reference implementation integration", async (t) => { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], type: "https://pdpp.dev/data-access", }, @@ -2397,8 +2526,8 @@ test("PDPP reference implementation integration", async (t) => { const { body: approved } = await approveGrantSuccess(asUrl, initiate.request_uri, "u1"); assert.equal(approved.grant.client.client_id, registration.body.client_id); - assert.equal(approved.grant.client.client_display.name, "Dynamic Longview"); - assert.equal(approved.grant.client.client_display.uri, "https://longview.example"); + assert.equal(approved.grant.client.client_display, null); + assert.equal(approved.grant.client.registration_mode, undefined); }); }); @@ -2446,7 +2575,7 @@ test("PDPP reference implementation integration", async (t) => { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], type: "https://pdpp.dev/data-access", }, @@ -2471,8 +2600,8 @@ test("PDPP reference implementation integration", async (t) => { const { body: approved } = await approveGrantSuccess(asUrl, initiate.request_uri, "u1"); - assert.equal(approved.grant.client.client_display.name, "Registered Longview"); - assert.equal(approved.grant.client.client_display.uri, "https://registered.longview.example"); + assert.equal(approved.grant.client.client_display, null); + assert.equal(approved.grant.client.registration_mode, undefined); }); } ); @@ -2496,7 +2625,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: registration.body.client_id, purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); assert.equal(initiate.status, 201); @@ -2522,8 +2651,8 @@ test("PDPP reference implementation integration", async (t) => { const approveResp = await approveGrantSuccess(asUrl, initiate.body.request_uri, "u1"); assert.equal(approveResp.status, 200); - assert.equal(approveResp.body.grant.client.client_display.name, "Updated Longview"); - assert.equal(approveResp.body.grant.client.client_display.uri, "https://updated.longview.example"); + assert.equal(approveResp.body.grant.client.client_display, null); + assert.equal(approveResp.body.grant.client.registration_mode, undefined); }); } ); @@ -2537,7 +2666,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); assert.equal(initiate.status, 201); @@ -2564,7 +2693,7 @@ test("PDPP reference implementation integration", async (t) => { assert.match(consentHtml, REGEXP_33); assert.match( consentHtml, - new RegExp(`<dt>Connector</dt><dd>${SPOTIFY_CONNECTOR_KEY.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}</dd>`) + new RegExp(`<dt>Connector</dt><dd>${SPOTIFY_SOURCE_ID.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}</dd>`) ); const approveResp = await approveGrantSuccess(asUrl, initiate.body.request_uri, "u1"); @@ -2572,7 +2701,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(approveResp.headers["request-id"], stagedRequestId); assert.equal(approveResp.headers["pdpp-reference-trace-id"], stagedTraceId); assert.equal(approveResp.body.grant.source.kind, "connector"); - assert.equal(approveResp.body.grant.source.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(approveResp.body.grant.source.id, SPOTIFY_SOURCE_ID); const { body: trace } = await fetchReferenceTrace(asUrl, stagedTraceId); // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. @@ -2581,7 +2710,7 @@ test("PDPP reference implementation integration", async (t) => { ); assert.ok(approvedEvent, "trace should keep consent.approved on the original staged trace"); assert.equal(approvedEvent.data?.source?.kind, "connector"); - assert.equal(approvedEvent.data?.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(approvedEvent.data?.source?.id, SPOTIFY_SOURCE_ID); // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. const grantIssuedEvent = (trace.data || []).find( @@ -2589,7 +2718,7 @@ test("PDPP reference implementation integration", async (t) => { ); assert.ok(grantIssuedEvent, "trace should keep grant.issued on the original staged trace"); assert.equal(grantIssuedEvent.data?.source?.kind, "connector"); - assert.equal(grantIssuedEvent.data?.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(grantIssuedEvent.data?.source?.id, SPOTIFY_SOURCE_ID); const forgedTraceResp = await fetch(`${asUrl}/_ref/traces/trc_forged_pending`); assert.equal(forgedTraceResp.status, 404); @@ -2606,7 +2735,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); assert.equal(initiate.status, 201); @@ -2698,7 +2827,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); assert.equal(initiate.status, 201); @@ -2740,7 +2869,7 @@ test("PDPP reference implementation integration", async (t) => { ); assert.ok(rejectedEvent, "trace should keep request.rejected on the original staged trace"); assert.equal(rejectedEvent.data?.source?.kind, "connector"); - assert.equal(rejectedEvent.data?.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(rejectedEvent.data?.source?.id, SPOTIFY_SOURCE_ID); }); } ); @@ -2754,7 +2883,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); assert.equal(initiate.status, 201); @@ -2790,7 +2919,7 @@ test("PDPP reference implementation integration", async (t) => { ); await t.test( - "consent display and approval reject staged requests whose registered client no longer exists", + "consent display preserves reviewed requests whose registered client no longer exists but approval rejects them", async () => { await withHarness(async ({ asUrl, spotifyManifest }) => { const registration = await registerDynamicClient(asUrl, { @@ -2803,43 +2932,52 @@ test("PDPP reference implementation integration", async (t) => { client_id: registration.body.client_id, purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); assert.equal(initiate.status, 201); + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: initiate.body.request_uri, subject_id: "u1" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewText = await reviewResp.text(); + assert.equal(reviewResp.status, 200, reviewText); + const reviewBody = requireJsonRecord(JSON.parse(reviewText), "consent review response"); + requireJsonRecord(reviewBody.approval_review, "consent review response.approval_review"); + const reviewRevision = requireString( + reviewBody.approval_review_revision, + "consent review response.approval_review_revision" + ); + await deleteRegisteredClient(registration.body.client_id); const consentResp = await fetch( `${asUrl}/consent?request_uri=${encodeURIComponent(initiate.body.request_uri)}` ); - assert.equal(consentResp.status, 400); - const consentRequestId = consentResp.headers.get("Request-Id"); - const consentTraceId = consentResp.headers.get("PDPP-Reference-Trace-Id"); - assert.ok(consentRequestId?.startsWith("req_")); - assert.ok(consentTraceId?.startsWith("trc_")); - const consentBody = parseErrorResponse(await consentResp.json()); - assert.equal(consentBody.error.code, "invalid_client"); - assert.match(consentBody.error.message, REGEXP_38); + assert.equal(consentResp.status, 200); + const consentHtml = await consentResp.text(); + assert.match(consentHtml, REGEXP_165); const approveResp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: initiate.body.request_uri, subject_id: "u1" }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approval_review_revision: reviewRevision, request_uri: initiate.body.request_uri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); assert.equal(approveResp.status, 400); const approveRequestId = approveResp.headers.get("Request-Id"); const approveTraceId = approveResp.headers.get("PDPP-Reference-Trace-Id"); - assert.equal(approveRequestId, consentRequestId); - assert.equal(approveTraceId, consentTraceId); + assert.ok(approveRequestId?.startsWith("req_")); + assert.ok(approveTraceId?.startsWith("trc_")); const approveBody = parseErrorResponse(await approveResp.json()); assert.equal(approveBody.error.code, "invalid_client"); assert.match(approveBody.error.message, REGEXP_39); - const { body: trace } = await fetchReferenceTrace(asUrl, consentTraceId); + const { body: trace } = await fetchReferenceTrace(asUrl, approveTraceId); // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. const rejectedEvents = (trace.data || []).filter( - (event) => event.event_type === "request.rejected" && event.request_id === consentRequestId + (event) => event.event_type === "request.rejected" && event.request_id === approveRequestId ); assert.ok(rejectedEvents.length >= 1, "trace should include request.rejected for consent-time client drift"); const rejectedEvent = rejectedEvents.find((event) => event.data?.error?.code === "invalid_client"); @@ -2847,7 +2985,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(rejectedEvent.object_type, "pending_consent"); assert.equal(rejectedEvent.client_id, registration.body.client_id); assert.equal(rejectedEvent.data?.source?.kind, "connector"); - assert.equal(rejectedEvent.data?.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(rejectedEvent.data?.source?.id, SPOTIFY_SOURCE_ID); assert.match(rejectedEvent.data?.error?.message || "", REGEXP_40); }); } @@ -2862,7 +3000,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ fields: ["id", "name"], name: "saved_tracks" }], }); assert.equal(initiate.status, 201); @@ -2890,7 +3028,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(deniedEvent.object_type, "pending_consent"); assert.equal(deniedEvent.status, "denied"); assert.equal(deniedEvent.data?.source?.kind, "connector"); - assert.equal(deniedEvent.data?.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(deniedEvent.data?.source?.id, SPOTIFY_SOURCE_ID); // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. const grantIssuedEvent = (trace.data || []).find((event) => event.event_type === "grant.issued"); @@ -2913,7 +3051,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: registration.body.client_id, purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); assert.equal(initiate.status, 201); @@ -3418,6 +3556,28 @@ test("PDPP reference implementation integration", async (t) => { const manifest = { connector_id: "time_range_test", display_name: "Time Range Test", + source_declaration: { + declaration_version: "time-range-test.v1", + display: { name: "Time Range Test" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation" }, + source: { id: "https://registry.pdpp.dev/connectors/time_range_test", kind: "connector" }, + streams: [ + { + name: "items", + primary_key: ["id"], + schema: { + properties: { + id: { type: "string" }, + value: { type: "string" }, + }, + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "append_only", + }, + ], + }, streams: [ { name: "items", @@ -3453,7 +3613,7 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(initiate.status, 400); - assert.equal(initiate.body.error.code, "invalid_request"); + assert.equal(initiate.body.error.code, "invalid_authorization_details"); assert.match(initiate.body.error.message, REGEXP_62); } finally { await closeServer(server); @@ -3641,7 +3801,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -3672,7 +3832,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); @@ -3709,6 +3869,28 @@ test("PDPP reference implementation integration", async (t) => { const manifest = { connector_id: "time_range_test", display_name: "Time Range Test", + source_declaration: { + declaration_version: "time-range-test.v1", + display: { name: "Time Range Test" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation" }, + source: { id: "https://registry.pdpp.dev/connectors/time_range_test", kind: "connector" }, + streams: [ + { + name: "items", + primary_key: ["id"], + schema: { + properties: { + id: { type: "string" }, + value: { type: "string" }, + }, + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "append_only", + }, + ], + }, streams: [ { name: "items", @@ -3772,7 +3954,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); @@ -3803,7 +3985,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ fields: ["id"], name: "top_artists" }], }); @@ -3834,7 +4016,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -3865,7 +4047,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -3888,7 +4070,7 @@ test("PDPP reference implementation integration", async (t) => { ); await t.test( - "consent display and approval reject persisted polyfill requests whose manifest_version no longer matches the current manifest", + "consent display and approval use the retained polyfill declaration snapshot when manifest_version drifts", async () => { await withHarness(async ({ asUrl, spotifyManifest }) => { const initiate = await startGrantRequest(asUrl, { @@ -3896,7 +4078,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -3905,21 +4087,22 @@ test("PDPP reference implementation integration", async (t) => { request.manifest_version = "999.0.0"; }); - const consentResp = await fetchConsentRejection(asUrl, initiate.body.request_uri); - assert.equal(consentResp.status, 400); - assert.equal(consentResp.body.error.code, "invalid_request"); - assert.match(consentResp.body.error.message, REGEXP_81); + const consentResp = await fetch( + `${asUrl}/consent?request_uri=${encodeURIComponent(initiate.body.request_uri)}` + ); + assert.equal(consentResp.status, 200); + assert.match(await consentResp.text(), REGEXP_29); - const approveResp = await approveGrantRejection(asUrl, initiate.body.request_uri, "owner_local"); - assert.equal(approveResp.status, 400); - assert.equal(approveResp.body.error.code, "invalid_request"); - assert.match(approveResp.body.error.message, REGEXP_82); + const approveResp = await approveGrantSuccess(asUrl, initiate.body.request_uri, "owner_local"); + assert.equal(approveResp.status, 200); + assert.equal(approveResp.body.grant.source.kind, "connector"); + assert.equal(approveResp.body.grant.source.id, sourceIdForConnectorId(spotifyManifest.connector_id)); }); } ); await t.test( - "consent display and approval reject persisted native requests whose manifest_version no longer matches the current manifest", + "consent display and approval use the retained native declaration snapshot when manifest_version drifts", async () => { await withNativeHarness(async ({ asUrl, nativeManifest }) => { const initiate = await startGrantRequest(asUrl, { @@ -3939,42 +4122,13 @@ test("PDPP reference implementation integration", async (t) => { const consentResp = await fetch( `${asUrl}/consent?request_uri=${encodeURIComponent(initiate.body.request_uri)}` ); - assert.equal(consentResp.status, 400); - const consentRequestId = consentResp.headers.get("Request-Id"); - const consentTraceId = consentResp.headers.get("PDPP-Reference-Trace-Id"); - assert.ok(consentRequestId?.startsWith("req_")); - assert.ok(consentTraceId?.startsWith("trc_")); - const consentBody = parseErrorResponse(await consentResp.json()); - assert.equal(consentBody.error.code, "invalid_request"); - assert.match(consentBody.error.message, REGEXP_83); - - const approveResp = await fetch(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: initiate.body.request_uri, subject_id: "employee_1" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - assert.equal(approveResp.status, 400); - const approveRequestId = approveResp.headers.get("Request-Id"); - const approveTraceId = approveResp.headers.get("PDPP-Reference-Trace-Id"); - assert.equal(approveRequestId, consentRequestId); - assert.equal(approveTraceId, consentTraceId); - const approveBody = parseErrorResponse(await approveResp.json()); - assert.equal(approveBody.error.code, "invalid_request"); - assert.match(approveBody.error.message, REGEXP_84); + assert.equal(consentResp.status, 200); + assert.match(await consentResp.text(), REGEXP_29); - const { body: trace } = await fetchReferenceTrace(asUrl, consentTraceId); - // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. - const rejectedEvents = (trace.data || []).filter( - (event) => event.event_type === "request.rejected" && event.request_id === consentRequestId - ); - assert.ok(rejectedEvents.length >= 1, "trace should include request.rejected for consent-time manifest drift"); - const rejectedEvent = rejectedEvents.find((event) => event.data?.error?.code === "invalid_request"); - assert.ok(rejectedEvent, "trace should preserve invalid_request rejection details"); - assert.equal(rejectedEvent.object_type, "pending_consent"); - assert.equal(rejectedEvent.client_id, "longview"); - assert.equal(rejectedEvent.data?.source?.kind, "provider_native"); - assert.equal(rejectedEvent.data?.source?.id, nativeManifest.provider_id); - assert.match(rejectedEvent.data?.error?.message || "", REGEXP_85); + const approveResp = await approveGrantSuccess(asUrl, initiate.body.request_uri, "employee_1"); + assert.equal(approveResp.status, 200); + assert.equal(approveResp.body.grant.source.kind, "provider_native"); + assert.equal(approveResp.body.grant.source.id, nativeManifest.provider_id); }); } ); @@ -4048,7 +4202,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(initiateResp.status, 400); const initiateBody = parseErrorResponse(await initiateResp.json()); - assert.equal(initiateBody.error.code, "invalid_request"); + assert.equal(initiateBody.error.code, "invalid_authorization_details"); assert.match(initiateBody.error.message, REGEXP_87); }); }); @@ -4060,7 +4214,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); @@ -4073,7 +4227,7 @@ test("PDPP reference implementation integration", async (t) => { if (event.data.source?.kind !== "connector") { continue; } - assert.equal(event.data.source.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(event.data.source.id, SPOTIFY_SOURCE_ID); assert.ok( !("storage_connector_id" in event.data), `connector event ${event.event_type} should not expose storage_connector_id` @@ -4091,7 +4245,7 @@ test("PDPP reference implementation integration", async (t) => { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], type: "https://pdpp.dev/data-access", }, @@ -4215,9 +4369,11 @@ test("PDPP reference implementation integration", async (t) => { const { body: introspection } = await introspectToken(asUrl, approved.token); assert.equal(introspection.active, true); - assert.ok(introspection.grant, "active native grant introspection must include a grant"); - assert.equal(introspection.grant.source?.kind, "provider_native"); - assert.equal(introspection.grant.source?.id, nativeManifest.provider_id); + assert.ok(Array.isArray(introspection.authorization_details)); + const [introspectedDetail] = introspection.authorization_details; + assert.ok(introspectedDetail, "active native grant introspection must include authorization_details"); + assert.equal(introspectedDetail.source?.kind, "provider_native"); + assert.equal(introspectedDetail.source?.id, nativeManifest.provider_id); assert.ok( !("grant_storage_connector_id" in introspection), "public introspection should not leak storage connector ids" @@ -4306,6 +4462,7 @@ test("PDPP reference implementation integration", async (t) => { const streamMetadataBody = parseResourceStreamMetadataResponse(await streamMetadataResp.json()); assert.equal(streamMetadataBody.object, "stream_metadata"); assert.equal(streamMetadataBody.name, "pay_statements"); + assert.deepEqual(streamMetadataBody.schema.required, []); const recordsResp = await fetch(`${rsUrl}/v1/streams/pay_statements/records`, { headers: { Authorization: `Bearer ${approved.token}` }, @@ -4323,10 +4480,10 @@ test("PDPP reference implementation integration", async (t) => { `${rsUrl}/v1/streams/pay_statements/records?connection_id=not_a_native_concept`, { headers: { Authorization: `Bearer ${approved.token}` } } ); - assert.equal(connectionScopedClientResp.status, 400); + assert.equal(connectionScopedClientResp.status, 401); const connectionScopedClientBody = parseErrorResponse(await connectionScopedClientResp.json()); - assert.equal(connectionScopedClientBody.error.code, "invalid_argument"); - assert.match(connectionScopedClientBody.error.message, REGEXP_88); + assert.equal(connectionScopedClientBody.error.code, "context.instance_mismatch"); + assert.match(connectionScopedClientBody.error.message, REGEXP_95); const recordResp = await fetch(`${rsUrl}/v1/streams/pay_statements/records/ps_2026_04_15`, { headers: { Authorization: `Bearer ${approved.token}` }, @@ -4480,7 +4637,7 @@ test("PDPP reference implementation integration", async (t) => { }, method: "POST", }); - assert.equal(revokeResp.status, 403); + assert.equal(revokeResp.status, 403, JSON.stringify(revokeResp.body)); const revokeError = parseErrorResponse(revokeResp.body); assert.equal(revokeError.error.code, "grant_invalid"); assert.match(revokeError.error.message, REGEXP_89); @@ -4558,6 +4715,7 @@ test("PDPP reference implementation integration", async (t) => { nativeManifest, quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -4589,6 +4747,7 @@ test("PDPP reference implementation integration", async (t) => { nativeManifest, quiet: true, rsPort: server.rsPort, + ...TEST_INTROSPECTION_SERVER_OPTS, }); async function assertMalformedNativeClientRead( @@ -4600,14 +4759,14 @@ test("PDPP reference implementation integration", async (t) => { const rejectedResp = await fetch(`${rsUrl}${path}`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejectedResp.status, 403); + assert.equal(rejectedResp.status, 404); const rejectedRequestId = rejectedResp.headers.get("Request-Id"); const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(rejectedRequestId?.startsWith("req_")); assert.ok(rejectedTraceId?.startsWith("trc_")); const rejectedBody = parseErrorResponse(await rejectedResp.json()); - assert.equal(rejectedBody.error.code, "grant_invalid"); - assert.match(rejectedBody.error.message, REGEXP_90); + assert.equal(rejectedBody.error.code, "not_found"); + assert.match(rejectedBody.error.message, REGEXP_164); assert.doesNotMatch(rejectedBody.error.message, REGEXP_162); } @@ -4635,6 +4794,7 @@ test("PDPP reference implementation integration", async (t) => { nativeManifest, quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -4666,6 +4826,7 @@ test("PDPP reference implementation integration", async (t) => { nativeManifest, quiet: true, rsPort: server.rsPort, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const streamsResp = await fetchJson(`${rsUrl}/v1/streams`, { @@ -4729,7 +4890,7 @@ test("PDPP reference implementation integration", async (t) => { `${rsUrl}/v1/streams/top_artists/records?connector_id=${encodeURIComponent(spotifyManifest.connector_id)}&limit=1`, { headers: { Authorization: `Bearer ${ownerToken}` } } ); - const visibleRecord = ownerRecordListResp.body.data?.[0]; + const [visibleRecord] = ownerRecordListResp.body.data; assert.ok(visibleRecord, "expected an owner-visible top_artists record before corrupting the grant binding"); const approved = await approveGrant(asUrl, "u1", { @@ -4737,12 +4898,12 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); const missingConnectorId = "missing_spotify_connector"; - const remappedGrant = JSON.parse(JSON.stringify(approved.grant)); + const remappedGrant = readPersistedGrantJson(approved.grant.grant_id); remappedGrant.source = { id: missingConnectorId, kind: "connector", @@ -4786,13 +4947,13 @@ test("PDPP reference implementation integration", async (t) => { const rejectedResp = await fetch(`${rsUrl}${path}`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejectedResp.status, 404); + assert.equal(rejectedResp.status, 403); const rejectedRequestId = rejectedResp.headers.get("Request-Id"); const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(rejectedRequestId?.startsWith("req_")); assert.ok(rejectedTraceId?.startsWith("trc_")); const rejectedBody = parseErrorResponse(await rejectedResp.json()); - assert.equal(rejectedBody.error.code, "not_found"); + assert.equal(rejectedBody.error.code, "grant_invalid"); assert.match(rejectedBody.error.message, REGEXP_94); const { body: timeline } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); @@ -4804,8 +4965,7 @@ test("PDPP reference implementation integration", async (t) => { `grant timeline should include query.received for broken polyfill ${queryShape} reads` ); assert.equal(queryReceivedEvent.data.query_shape, queryShape); - assert.equal(queryReceivedEvent.data.source?.kind, "connector"); - assert.equal(queryReceivedEvent.data.source?.id, missingConnectorId); + assert.equal(queryReceivedEvent.data.source, undefined); if (streamId) { assert.equal(queryReceivedEvent.stream_id, streamId); } @@ -4822,10 +4982,9 @@ test("PDPP reference implementation integration", async (t) => { ); assert.equal(rejectedEvent.trace_id, rejectedTraceId); assert.equal(rejectedEvent.data.query_shape, queryShape); - assert.equal(rejectedEvent.data.source?.kind, "connector"); - assert.equal(rejectedEvent.data.source?.id, missingConnectorId); - assert.equal(rejectedEvent.data.error?.code, "not_found"); - assert.match(rejectedEvent.data.error?.message || "", REGEXP_95); + assert.equal(rejectedEvent.data.source, undefined); + assert.equal(rejectedEvent.data.error?.code, "grant_invalid"); + assert.match(rejectedEvent.data.error?.message || "", REGEXP_89); if (streamId) { assert.equal(rejectedEvent.stream_id, streamId); } @@ -4867,6 +5026,7 @@ test("PDPP reference implementation integration", async (t) => { nativeManifest, quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; @@ -4888,7 +5048,7 @@ test("PDPP reference implementation integration", async (t) => { (event) => event.event_type === "grant.revoked" ).length; - const malformedGrant = JSON.parse(JSON.stringify(approved.grant)); + const malformedGrant = readPersistedGrantJson(approved.grant.grant_id); malformedGrant.source = undefined; getDb() @@ -4906,6 +5066,7 @@ test("PDPP reference implementation integration", async (t) => { nativeManifest, quiet: true, rsPort: server.rsPort, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const introspectResp = await introspectFormToken(asUrl, approved.token); @@ -4924,7 +5085,7 @@ test("PDPP reference implementation integration", async (t) => { }, method: "POST", }); - assert.equal(revokeResp.status, 403); + assert.equal(revokeResp.status, 403, JSON.stringify(revokeResp.body)); const revokeError = parseErrorResponse(revokeResp.body); assert.equal(revokeError.error.code, "grant_invalid"); assert.match(revokeError.error.message, REGEXP_96); @@ -4957,6 +5118,7 @@ test("PDPP reference implementation integration", async (t) => { dynamicClientRegistrationInitialAccessTokens: [TEST_DCR_INITIAL_ACCESS_TOKEN], quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; @@ -4967,13 +5129,14 @@ test("PDPP reference implementation integration", async (t) => { method: "POST", }); assert.equal(registerResp.status, 201); + await seedDefaultGrantInstance(spotifyManifest.connector_id, "owner_local", "Spotify"); const approved = await approveGrant(asUrl, "owner_local", { access_mode: "continuous", client_id: "longview", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }, { name: "recently_played" }], }); @@ -4983,7 +5146,7 @@ test("PDPP reference implementation integration", async (t) => { (event) => event.event_type === "grant.revoked" ).length; - const malformedGrant = JSON.parse(JSON.stringify(approved.grant)); + const malformedGrant = readPersistedGrantJson(approved.grant.grant_id); malformedGrant.source = undefined; getDb() @@ -5001,6 +5164,7 @@ test("PDPP reference implementation integration", async (t) => { dynamicClientRegistrationInitialAccessTokens: [TEST_DCR_INITIAL_ACCESS_TOKEN], quiet: true, rsPort: server.rsPort, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const reRegisterResp = await fetchJson(`${asUrl}/connectors`, { @@ -5059,6 +5223,7 @@ test("PDPP reference implementation integration", async (t) => { dynamicClientRegistrationInitialAccessTokens: [TEST_DCR_INITIAL_ACCESS_TOKEN], quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -5070,13 +5235,14 @@ test("PDPP reference implementation integration", async (t) => { method: "POST", }); assert.equal(registerResp.status, 201); + await seedDefaultGrantInstance(spotifyManifest.connector_id, "owner_local", "Spotify"); const approved = await approveGrant(asUrl, "owner_local", { access_mode: "continuous", client_id: "longview", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); const { body: timelineBeforeRevoke } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); @@ -5085,8 +5251,10 @@ test("PDPP reference implementation integration", async (t) => { (event) => event.event_type === "grant.revoked" ).length; - const malformedGrant = JSON.parse(JSON.stringify(approved.grant)); - malformedGrant.streams = [{ name: "missing_stream" }]; + const malformedGrant = readPersistedGrantJson(approved.grant.grant_id); + malformedGrant.streams = requireJsonRecordArray(malformedGrant.streams, "persisted grant.streams").map( + (stream, index) => (index === 0 ? { ...stream, name: "missing_stream" } : stream) + ); getDb() .prepare(` @@ -5098,14 +5266,13 @@ test("PDPP reference implementation integration", async (t) => { const introspectResp = await introspectFormToken(asUrl, approved.token); assert.equal(introspectResp.status, 200); - assert.equal(introspectResp.body.active, false); - assert.equal(introspectResp.body.inactive_reason, "grant_invalid"); + assert.equal(introspectResp.body.active, true); const streamsResp = await fetchJson(`${rsUrl}/v1/streams`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(streamsResp.status, 403); - assert.equal(streamsResp.body.error.code, "grant_invalid"); + assert.equal(streamsResp.status, 404); + assert.equal(streamsResp.body.error.code, "stream_not_declared"); assert.match(streamsResp.body.error.message, REGEXP_98); const revokeResp = await fetchJson(`${asUrl}/grants/${approved.grant.grant_id}/revoke`, { @@ -5115,10 +5282,7 @@ test("PDPP reference implementation integration", async (t) => { }, method: "POST", }); - assert.equal(revokeResp.status, 403); - const revokeError = parseErrorResponse(revokeResp.body); - assert.equal(revokeError.error.code, "grant_invalid"); - assert.match(revokeError.error.message, REGEXP_99); + assert.equal(revokeResp.status, 200); const { body: timelineAfterRevoke } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. @@ -5127,8 +5291,8 @@ test("PDPP reference implementation integration", async (t) => { ).length; assert.equal( revokedEventsAfter, - revokedEventsBefore, - "manifest-drifted grants should not emit degraded grant.revoked artifacts" + revokedEventsBefore + 1, + "a current-shape grant remains explicitly revocable after stream declaration drift" ); } finally { await closeServer(server); @@ -5148,6 +5312,7 @@ test("PDPP reference implementation integration", async (t) => { dynamicClientRegistrationInitialAccessTokens: [TEST_DCR_INITIAL_ACCESS_TOKEN], quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -5159,13 +5324,14 @@ test("PDPP reference implementation integration", async (t) => { method: "POST", }); assert.equal(registerResp.status, 201); + await seedDefaultGrantInstance(spotifyManifest.connector_id, "owner_local", "Spotify"); const approved = await approveGrant(asUrl, "owner_local", { access_mode: "continuous", client_id: "longview", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); const { body: timelineBeforeRevoke } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); @@ -5174,7 +5340,7 @@ test("PDPP reference implementation integration", async (t) => { (event) => event.event_type === "grant.revoked" ).length; - const malformedGrant = JSON.parse(JSON.stringify(approved.grant)); + const malformedGrant = readPersistedGrantJson(approved.grant.grant_id); malformedGrant.manifest_version = "999.0.0"; getDb() @@ -5195,7 +5361,7 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(metadataResp.status, 403); assert.equal(metadataResp.body.error.code, "grant_invalid"); - assert.match(metadataResp.body.error.message, REGEXP_100); + assert.match(metadataResp.body.error.message, REGEXP_101); const revokeResp = await fetchJson(`${asUrl}/grants/${approved.grant.grant_id}/revoke`, { headers: { @@ -5244,8 +5410,10 @@ test("PDPP reference implementation integration", async (t) => { (event) => event.event_type === "grant.revoked" ).length; - const malformedGrant = JSON.parse(JSON.stringify(approved.grant)); - malformedGrant.streams = [{ name: "missing_stream" }]; + const malformedGrant = readPersistedGrantJson(approved.grant.grant_id); + malformedGrant.streams = requireJsonRecordArray(malformedGrant.streams, "persisted grant.streams").map( + (stream, index) => (index === 0 ? { ...stream, name: "missing_stream" } : stream) + ); getDb() .prepare(` @@ -5257,15 +5425,14 @@ test("PDPP reference implementation integration", async (t) => { const introspectResp = await introspectFormToken(asUrl, approved.token); assert.equal(introspectResp.status, 200); - assert.equal(introspectResp.body.active, false); - assert.equal(introspectResp.body.inactive_reason, "grant_invalid"); + assert.equal(introspectResp.body.active, true); const metadataResp = await fetchJson(`${rsUrl}/v1/streams/pay_statements`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(metadataResp.status, 403); - assert.equal(metadataResp.body.error.code, "grant_invalid"); - assert.match(metadataResp.body.error.message, REGEXP_102); + assert.equal(metadataResp.status, 401); + assert.equal(metadataResp.body.error.code, "context.stream_not_allowed"); + assert.match(metadataResp.body.error.message, REGEXP_95); const revokeResp = await fetchJson(`${asUrl}/grants/${approved.grant.grant_id}/revoke`, { headers: { @@ -5274,10 +5441,7 @@ test("PDPP reference implementation integration", async (t) => { }, method: "POST", }); - assert.equal(revokeResp.status, 403); - const revokeError = parseErrorResponse(revokeResp.body); - assert.equal(revokeError.error.code, "grant_invalid"); - assert.match(revokeError.error.message, REGEXP_103); + assert.equal(revokeResp.status, 200); const { body: timelineAfterRevoke } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. @@ -5286,8 +5450,8 @@ test("PDPP reference implementation integration", async (t) => { ).length; assert.equal( revokedEventsAfter, - revokedEventsBefore, - "manifest-drifted native grants should not emit degraded grant.revoked artifacts" + revokedEventsBefore + 1, + "a current-shape native grant remains explicitly revocable after stream declaration drift" ); }); } @@ -5311,7 +5475,7 @@ test("PDPP reference implementation integration", async (t) => { (event) => event.event_type === "grant.revoked" ).length; - const malformedGrant = JSON.parse(JSON.stringify(approved.grant)); + const malformedGrant = readPersistedGrantJson(approved.grant.grant_id); malformedGrant.manifest_version = "999.0.0"; getDb() @@ -5332,7 +5496,7 @@ test("PDPP reference implementation integration", async (t) => { }); assert.equal(metadataResp.status, 403); assert.equal(metadataResp.body.error.code, "grant_invalid"); - assert.match(metadataResp.body.error.message, REGEXP_104); + assert.match(metadataResp.body.error.message, REGEXP_105); const revokeResp = await fetchJson(`${asUrl}/grants/${approved.grant.grant_id}/revoke`, { headers: { @@ -5381,7 +5545,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(initiateResp.status, 400); const initiateBody = parseErrorResponse(await initiateResp.json()); - assert.equal(initiateBody.error.code, "invalid_request"); + assert.equal(initiateBody.error.code, "invalid_authorization_details"); assert.match(initiateBody.error.message, REGEXP_106); }); }); @@ -5413,7 +5577,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(initiateResp.status, 400); const initiateBody = parseErrorResponse(await initiateResp.json()); - assert.equal(initiateBody.error.code, "invalid_request"); + assert.equal(initiateBody.error.code, "invalid_authorization_details"); assert.match(initiateBody.error.message, REGEXP_107); }); } @@ -5536,7 +5700,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/concert_recommendation", purpose_description: "Recommend concerts and nearby live events", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -5598,7 +5762,7 @@ test("PDPP reference implementation integration", async (t) => { const streamsBody = parseResourceStreamListResponse(await streamsResp.json()); assert.deepEqual( streamsBody.data.map((stream) => stream.name), - ["benefits_enrollments", "equity_grants", "pay_statements"] + ["pay_statements", "equity_grants", "benefits_enrollments"] ); const streamMetadataResp = await fetch(`${rsUrl}/v1/streams/pay_statements`, { @@ -5610,12 +5774,17 @@ test("PDPP reference implementation integration", async (t) => { assert.ok(streamMetadataRequestId?.startsWith("req_")); assert.ok(streamMetadataTraceId?.startsWith("trc_qry_")); const streamMetadataBody = parseResourceStreamMetadataResponse(await streamMetadataResp.json()); - const payStatementsManifest = nativeManifest.streams.find((stream) => stream.name === "pay_statements"); + const payStatementsManifest = nativeManifest.source_declaration.streams.find( + (stream) => stream.name === "pay_statements" + ); + const nativeStorageStreams = requireJsonRecordArray(nativeManifest.streams, "native manifest streams"); + const payStatementsStorageManifest = nativeStorageStreams.find((stream) => stream.name === "pay_statements"); assert.ok(payStatementsManifest, "expected pay_statements native manifest entry"); + assert.ok(payStatementsStorageManifest, "expected pay_statements native storage manifest entry"); assert.equal(streamMetadataBody.name, "pay_statements"); - assert.equal(streamMetadataBody.semantics, payStatementsManifest.semantics); + assert.equal(streamMetadataBody.semantics, payStatementsStorageManifest.semantics); assert.equal(streamMetadataBody.consent_time_field, payStatementsManifest.consent_time_field); - assert.deepEqual(streamMetadataBody.primary_key, [payStatementsManifest.primary_key]); + assert.deepEqual(streamMetadataBody.primary_key, payStatementsManifest.primary_key); const recordsResp = await fetch(`${rsUrl}/v1/streams/pay_statements/records`, { headers: { Authorization: `Bearer ${ownerToken}` }, @@ -5635,10 +5804,10 @@ test("PDPP reference implementation integration", async (t) => { `${rsUrl}/v1/streams/pay_statements/records?connection_id=not_a_native_concept`, { headers: { Authorization: `Bearer ${ownerToken}` } } ); - assert.equal(connectionScopedOwnerResp.status, 400); + assert.equal(connectionScopedOwnerResp.status, 404); const connectionScopedOwnerBody = parseErrorResponse(await connectionScopedOwnerResp.json()); - assert.equal(connectionScopedOwnerBody.error.code, "invalid_argument"); - assert.match(connectionScopedOwnerBody.error.message, REGEXP_114); + assert.equal(connectionScopedOwnerBody.error.code, "connection_not_found"); + assert.match(connectionScopedOwnerBody.error.message, REGEXP_163); const { body: streamsTrace } = await fetchReferenceTrace(asUrl, streamsTraceId); const { body: streamMetadataTrace } = await fetchReferenceTrace(asUrl, streamMetadataTraceId); @@ -5936,7 +6105,7 @@ test("PDPP reference implementation integration", async (t) => { `${rsUrl}/v1/streams/top_artists/records?connector_id=${encodeURIComponent(spotifyManifest.connector_id)}&limit=1`, { headers: { Authorization: `Bearer ${ownerToken}` } } ); - const visibleRecord = ownerRecordListResp.body.data?.[0]; + const [visibleRecord] = ownerRecordListResp.body.data; assert.ok(visibleRecord, "expected an owner-visible top_artists record before corrupting the manifest"); getDb() @@ -6192,7 +6361,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -6275,7 +6444,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -6357,7 +6526,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -6535,7 +6704,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -6609,7 +6778,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -6660,7 +6829,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -6712,7 +6881,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Generate a one-time concert recommendation snapshot", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists" }], }); @@ -7215,7 +7384,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts based on a chosen artist subset", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [ { name: "top_artists", @@ -7257,13 +7426,11 @@ test("PDPP reference implementation integration", async (t) => { ); assert.equal(topArtistsSummary.record_count, clientRecords.length); assert.deepEqual( - // biome-ignore lint/suspicious/useArraySortCompare: Fixture values use the runtime default sort semantics under test. clientRecords.map((record) => record.id).sort(), ["spotify:artist:0C0XlULifJtAgn6ZNCW2eu", "spotify:artist:1Xyo4u8uXC1ZmMpatF05PJ"].sort() ); const expectedLastUpdated = - // biome-ignore lint/suspicious/useArraySortCompare: Fixture values use the runtime default sort semantics under test. clientRecords .map((record) => record.emitted_at) .sort() @@ -7286,7 +7453,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts from recent listening only", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [ { name: "top_artists", @@ -7329,7 +7496,6 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(topArtistsSummary.record_count, clientRecords.length); const expectedLastUpdated = - // biome-ignore lint/suspicious/useArraySortCompare: Fixture values use the runtime default sort semantics under test. clientRecords .map((record) => record.emitted_at) .sort() @@ -7351,20 +7517,20 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts using top artists only", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); const rejectedResp = await fetch(`${rsUrl}/v1/streams/recently_played`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejectedResp.status, 403); + assert.equal(rejectedResp.status, 401); const rejectedRequestId = rejectedResp.headers.get("Request-Id"); const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(rejectedRequestId?.startsWith("req_")); assert.ok(rejectedTraceId, "rejected client metadata reads should carry a reference trace id"); const rejectedBody = parseErrorResponse(await rejectedResp.json()); - assert.equal(rejectedBody.error.code, "grant_stream_not_allowed"); + assert.equal(rejectedBody.error.code, "context.stream_not_allowed"); assert.match(rejectedBody.error.message, REGEXP_142); const { body: timeline } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); @@ -7378,8 +7544,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(queryReceivedEvent.trace_id, rejectedTraceId); assert.equal(queryReceivedEvent.stream_id, "recently_played"); assert.equal(queryReceivedEvent.data.query_shape, "stream_metadata"); - assert.equal(queryReceivedEvent.data.source?.kind, "connector"); - assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(queryReceivedEvent.data.source, undefined); const rejectedEvent = timeline.data.find( (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId @@ -7388,9 +7553,8 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(rejectedEvent.trace_id, rejectedTraceId); assert.equal(rejectedEvent.stream_id, "recently_played"); assert.equal(rejectedEvent.data.query_shape, "stream_metadata"); - assert.equal(rejectedEvent.data.source?.kind, "connector"); - assert.equal(rejectedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); - assert.equal(rejectedEvent.data.error?.code, "grant_stream_not_allowed"); + assert.equal(rejectedEvent.data.source, undefined); + assert.equal(rejectedEvent.data.error?.code, "context.stream_not_allowed"); assert.match(rejectedEvent.data.error?.message || "", REGEXP_143); const servedEvent = timeline.data.find( @@ -7413,20 +7577,20 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts using top artists only", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); const rejectedResp = await fetch(`${rsUrl}/v1/streams/recently_played/records?limit=1`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejectedResp.status, 403); + assert.equal(rejectedResp.status, 401); const rejectedRequestId = rejectedResp.headers.get("Request-Id"); const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(rejectedRequestId?.startsWith("req_")); assert.ok(rejectedTraceId, "rejected client record-list reads should carry a reference trace id"); const rejectedBody = parseErrorResponse(await rejectedResp.json()); - assert.equal(rejectedBody.error.code, "grant_stream_not_allowed"); + assert.equal(rejectedBody.error.code, "context.stream_not_allowed"); assert.match(rejectedBody.error.message, REGEXP_144); const { body: timeline } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); @@ -7437,8 +7601,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(queryReceivedEvent.trace_id, rejectedTraceId); assert.equal(queryReceivedEvent.stream_id, "recently_played"); assert.equal(queryReceivedEvent.data.query_shape, "record_list"); - assert.equal(queryReceivedEvent.data.source?.kind, "connector"); - assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(queryReceivedEvent.data.source, undefined); const rejectedEvent = timeline.data.find( (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId @@ -7447,7 +7610,8 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(rejectedEvent.trace_id, rejectedTraceId); assert.equal(rejectedEvent.stream_id, "recently_played"); assert.equal(rejectedEvent.data.query_shape, "record_list"); - assert.equal(rejectedEvent.data.error?.code, "grant_stream_not_allowed"); + assert.equal(rejectedEvent.data.source, undefined); + assert.equal(rejectedEvent.data.error?.code, "context.stream_not_allowed"); assert.match(rejectedEvent.data.error?.message || "", REGEXP_145); const servedEvent = timeline.data.find( @@ -7469,7 +7633,7 @@ test("PDPP reference implementation integration", async (t) => { `${rsUrl}/v1/streams/saved_tracks/records?connector_id=${encodeURIComponent(spotifyManifest.connector_id)}&limit=1`, { headers: { Authorization: `Bearer ${ownerToken}` } } ); - const hiddenRecord = ownerListResp.body.data?.[0]; + const [hiddenRecord] = ownerListResp.body.data; assert.ok(hiddenRecord, "expected an owner-visible saved_tracks record outside the client grant"); const approved = await approveGrant(asUrl, "u1", { @@ -7477,7 +7641,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts using top artists only", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); @@ -7487,13 +7651,13 @@ test("PDPP reference implementation integration", async (t) => { headers: { Authorization: `Bearer ${approved.token}` }, } ); - assert.equal(rejectedResp.status, 403); + assert.equal(rejectedResp.status, 401); const rejectedRequestId = rejectedResp.headers.get("Request-Id"); const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(rejectedRequestId?.startsWith("req_")); assert.ok(rejectedTraceId, "rejected client record-detail reads should carry a reference trace id"); const rejectedBody = parseErrorResponse(await rejectedResp.json()); - assert.equal(rejectedBody.error.code, "grant_stream_not_allowed"); + assert.equal(rejectedBody.error.code, "context.stream_not_allowed"); assert.match(rejectedBody.error.message, REGEXP_146); const { body: timeline } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); @@ -7504,8 +7668,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(queryReceivedEvent.trace_id, rejectedTraceId); assert.equal(queryReceivedEvent.stream_id, "saved_tracks"); assert.equal(queryReceivedEvent.data.query_shape, "record_detail"); - assert.equal(queryReceivedEvent.data.source?.kind, "connector"); - assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(queryReceivedEvent.data.source, undefined); const rejectedEvent = timeline.data.find( (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId @@ -7514,7 +7677,8 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(rejectedEvent.trace_id, rejectedTraceId); assert.equal(rejectedEvent.stream_id, "saved_tracks"); assert.equal(rejectedEvent.data.query_shape, "record_detail"); - assert.equal(rejectedEvent.data.error?.code, "grant_stream_not_allowed"); + assert.equal(rejectedEvent.data.source, undefined); + assert.equal(rejectedEvent.data.error?.code, "context.stream_not_allowed"); assert.match(rejectedEvent.data.error?.message || "", REGEXP_147); const servedEvent = timeline.data.find( @@ -7537,7 +7701,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts using a chosen artist subset", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [ { name: "top_artists", @@ -7569,7 +7733,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(queryReceivedEvent.data.query_shape, "record_detail"); assert.equal(queryReceivedEvent.data.requested_record_id, rejectedId); assert.equal(queryReceivedEvent.data.source?.kind, "connector"); - assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_SOURCE_ID); const rejectedEvent = timeline.data.find( (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId @@ -7580,7 +7744,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(rejectedEvent.data.query_shape, "record_detail"); assert.equal(rejectedEvent.data.requested_record_id, rejectedId); assert.equal(rejectedEvent.data.source?.kind, "connector"); - assert.equal(rejectedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(rejectedEvent.data.source?.id, SPOTIFY_SOURCE_ID); assert.equal(rejectedEvent.data.error?.code, "not_found"); assert.match(rejectedEvent.data.error?.message || "", REGEXP_149); @@ -7605,7 +7769,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts from recent listening only", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [ { name: "top_artists", @@ -7663,7 +7827,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(queryReceivedEvent.data.query_shape, "record_detail"); assert.equal(queryReceivedEvent.data.requested_record_id, hiddenRecord.id); assert.equal(queryReceivedEvent.data.source?.kind, "connector"); - assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_SOURCE_ID); const rejectedEvent = timeline.data.find( (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId @@ -7677,7 +7841,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(rejectedEvent.data.query_shape, "record_detail"); assert.equal(rejectedEvent.data.requested_record_id, hiddenRecord.id); assert.equal(rejectedEvent.data.source?.kind, "connector"); - assert.equal(rejectedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(rejectedEvent.data.source?.id, SPOTIFY_SOURCE_ID); assert.equal(rejectedEvent.data.error?.code, "not_found"); assert.match(rejectedEvent.data.error?.message || "", REGEXP_151); @@ -7715,7 +7879,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts using only the latest permitted artist", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [ { name: "top_artists", @@ -7751,7 +7915,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(queryReceivedEvent.stream_id, "top_artists"); assert.equal(queryReceivedEvent.data.query_shape, "record_list"); assert.equal(queryReceivedEvent.data.source?.kind, "connector"); - assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_SOURCE_ID); const servedEvent = timeline.data.find( (event) => event.event_type === "disclosure.served" && event.object_id === requestId @@ -7763,11 +7927,11 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(servedEvent.data.record_count, 1); assert.equal(servedEvent.data.has_more, false); assert.equal(servedEvent.data.source?.kind, "connector"); - assert.equal(servedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(servedEvent.data.source?.id, SPOTIFY_SOURCE_ID); }); }); - await t.test("client stream metadata remains source-level even when the grant narrows fields", async () => { + await t.test("client stream metadata preserves source fields and marks grant usability", async () => { await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { const ownerToken = await issueOwnerToken(asUrl, "u1"); await seedSpotify(rsUrl, spotifyManifest, ownerToken); @@ -7777,7 +7941,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts using the basic top-artist subset", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [ { fields: ["id", "name", "genres"], @@ -7794,23 +7958,18 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(metadataBody.object, "stream_metadata"); // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. const metadataFields = Object.keys(metadataBody.schema.properties || {}).sort(); - assert.ok(metadataFields.includes("id")); - assert.ok(metadataFields.includes("name")); - assert.ok(metadataFields.includes("genres")); - assert.ok(metadataFields.includes("popularity")); - assert.ok(metadataFields.includes("followers")); - assert.ok(metadataFields.includes("image_url")); - assert.ok(metadataFields.includes("source_updated_at")); - // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. - // biome-ignore lint/suspicious/useArraySortCompare: Fixture values use the runtime default sort semantics under test. - assert.deepEqual((metadataBody.schema.required || []).sort(), ["id", "name"]); + assert.deepEqual(metadataFields, ["genres", "id", "name"]); + assert.deepEqual(metadataBody.schema.required, []); // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. - // biome-ignore lint/suspicious/useArraySortCompare: Fixture values use the runtime default sort semantics under test. - assert.deepEqual((metadataBody.views || []).map((view) => view.id).sort(), ["basic", "full"]); - // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. - assert.ok("popularity" in (metadataBody.schema.properties || {})); - // biome-ignore lint/suspicious/noUnnecessaryConditions: Runtime guard protects an untyped external/test boundary. - assert.ok((metadataBody.views || []).some((view) => view.id === "full")); + assert.deepEqual((metadataBody.views || []).map((view) => view.id).sort(), []); + const fieldCapabilities = metadataBody.field_capabilities as Record< + "genres" | "id" | "name" | "popularity", + { exact_filter?: unknown; granted?: boolean } + >; + assert.equal(fieldCapabilities.id.granted, true); + assert.equal(fieldCapabilities.name.granted, true); + assert.equal(fieldCapabilities.genres.granted, true); + assert.equal(fieldCapabilities.popularity, undefined); }); }); @@ -7826,7 +7985,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts using the basic top-artist subset", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [ { fields: ["id", "name", "genres"], @@ -7878,7 +8037,7 @@ test("PDPP reference implementation integration", async (t) => { ); await t.test( - "field-limited client grants reject filter fields outside the grant and preserve the rejection in the timeline", + "client grants reject exact filters before current metadata and preserve the rejection in the timeline", async () => { await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { const ownerToken = await issueOwnerToken(asUrl, "u1"); @@ -7889,7 +8048,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts using the basic top-artist subset", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [ { fields: ["id", "name", "genres"], @@ -7901,14 +8060,23 @@ test("PDPP reference implementation integration", async (t) => { const rejectedResp = await fetch(`${rsUrl}/v1/streams/top_artists/records?filter[popularity]=96`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejectedResp.status, 403); + assert.equal(rejectedResp.status, 400); const rejectedRequestId = rejectedResp.headers.get("Request-Id"); const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(rejectedRequestId?.startsWith("req_")); assert.ok(rejectedTraceId?.startsWith("trc_")); const rejectedBody = parseErrorResponse(await rejectedResp.json()); - assert.equal(rejectedBody.error.code, "field_not_granted"); - assert.match(rejectedBody.error.message, REGEXP_152); + assert.equal(rejectedBody.error.code, "invalid_request"); + assert.match(rejectedBody.error.message, CLIENT_FILTER_ERROR); + + const rangeRejected = await fetch( + `${rsUrl}/v1/streams/top_artists/records?filter[source_updated_at][gte]=2026-01-01T00:00:00Z`, + { headers: { Authorization: `Bearer ${approved.token}` } } + ); + assert.equal(rangeRejected.status, 400); + const rangeBody = parseErrorResponse(await rangeRejected.json()); + assert.equal(rangeBody.error.code, "invalid_request"); + assert.match(rangeBody.error.message, CLIENT_FILTER_ERROR); const { body: timeline } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); const queryReceivedEvent = timeline.data.find( @@ -7922,7 +8090,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(queryReceivedEvent.stream_id, "top_artists"); assert.equal(queryReceivedEvent.data.query_shape, "record_list"); assert.equal(queryReceivedEvent.data.source?.kind, "connector"); - assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_SOURCE_ID); const rejectedEvent = timeline.data.find( (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId @@ -7935,9 +8103,9 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(rejectedEvent.stream_id, "top_artists"); assert.equal(rejectedEvent.data.query_shape, "record_list"); assert.equal(rejectedEvent.data.source?.kind, "connector"); - assert.equal(rejectedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); - assert.equal(rejectedEvent.data.error?.code, "field_not_granted"); - assert.match(rejectedEvent.data.error?.message || "", REGEXP_153); + assert.equal(rejectedEvent.data.source?.id, SPOTIFY_SOURCE_ID); + assert.equal(rejectedEvent.data.error?.code, "invalid_request"); + assert.match(rejectedEvent.data.error?.message || "", CLIENT_FILTER_ERROR); const servedEvent = timeline.data.find( (event) => event.event_type === "disclosure.served" && event.object_id === rejectedRequestId @@ -7951,81 +8119,78 @@ test("PDPP reference implementation integration", async (t) => { } ); - await t.test( - "field-limited client grants reject manifest views that expand beyond granted fields and preserve the rejection in the timeline", - async () => { - await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { - const ownerToken = await issueOwnerToken(asUrl, "u1"); - await seedSpotify(rsUrl, spotifyManifest, ownerToken); + await t.test("client grants reject query-time views without interpreting current manifest definitions", async () => { + await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { + const ownerToken = await issueOwnerToken(asUrl, "u1"); + await seedSpotify(rsUrl, spotifyManifest, ownerToken); - const approved = await approveGrant(asUrl, "u1", { - access_mode: "single_use", - client_id: "concert_recommendation_app", - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Recommend concerts using the basic top-artist subset", - source: { id: spotifyManifest.connector_id, kind: "connector" }, - streams: [ - { - fields: ["id", "name", "genres"], - name: "top_artists", - }, - ], - }); + const approved = await approveGrant(asUrl, "u1", { + access_mode: "single_use", + client_id: "concert_recommendation_app", + purpose_code: "https://pdpp.dev/purpose/personalization", + purpose_description: "Recommend concerts using the basic top-artist subset", + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, + streams: [ + { + fields: ["id", "name", "genres"], + name: "top_artists", + }, + ], + }); - const rejectedResp = await fetch(`${rsUrl}/v1/streams/top_artists/records?view=full`, { - headers: { Authorization: `Bearer ${approved.token}` }, - }); - assert.equal(rejectedResp.status, 403); - const rejectedRequestId = rejectedResp.headers.get("Request-Id"); - const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); - assert.ok(rejectedRequestId?.startsWith("req_")); - assert.ok(rejectedTraceId?.startsWith("trc_")); - const rejectedBody = parseErrorResponse(await rejectedResp.json()); - assert.equal(rejectedBody.error.code, "field_not_granted"); - assert.match(rejectedBody.error.message, REGEXP_154); + const rejectedResp = await fetch(`${rsUrl}/v1/streams/top_artists/records?view=full`, { + headers: { Authorization: `Bearer ${approved.token}` }, + }); + assert.equal(rejectedResp.status, 400); + const rejectedRequestId = rejectedResp.headers.get("Request-Id"); + const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); + assert.ok(rejectedRequestId?.startsWith("req_")); + assert.ok(rejectedTraceId?.startsWith("trc_")); + const rejectedBody = parseErrorResponse(await rejectedResp.json()); + assert.equal(rejectedBody.error.code, "invalid_request"); + assert.match(rejectedBody.error.message, REGEXP_154); - const { body: timeline } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); - const queryReceivedEvent = timeline.data.find( - (event) => event.event_type === "query.received" && event.object_id === rejectedRequestId - ); - assert.ok( - queryReceivedEvent, - "grant timeline should include query.received for rejected view-based record-list reads" - ); - assert.equal(queryReceivedEvent.trace_id, rejectedTraceId); - assert.equal(queryReceivedEvent.stream_id, "top_artists"); - assert.equal(queryReceivedEvent.data.query_shape, "record_list"); - assert.equal(queryReceivedEvent.data.requested_view, "full"); - assert.equal(queryReceivedEvent.data.source?.kind, "connector"); - assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + const { body: timeline } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); + const queryReceivedEvent = timeline.data.find( + (event) => event.event_type === "query.received" && event.object_id === rejectedRequestId + ); + assert.ok( + queryReceivedEvent, + "grant timeline should include query.received for rejected view-based record-list reads" + ); + assert.equal(queryReceivedEvent.trace_id, rejectedTraceId); + assert.equal(queryReceivedEvent.stream_id, "top_artists"); + assert.equal(queryReceivedEvent.data.query_shape, "record_list"); + assert.equal(queryReceivedEvent.data.requested_view, "full"); + assert.equal(queryReceivedEvent.data.source?.kind, "connector"); + assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_SOURCE_ID); - const rejectedEvent = timeline.data.find( - (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId - ); - assert.ok( - rejectedEvent, - "grant timeline should include query.rejected for rejected view-based record-list reads" - ); - assert.equal(rejectedEvent.trace_id, rejectedTraceId); - assert.equal(rejectedEvent.stream_id, "top_artists"); - assert.equal(rejectedEvent.data.query_shape, "record_list"); - assert.equal(rejectedEvent.data.requested_view, "full"); - assert.equal(rejectedEvent.data.source?.kind, "connector"); - assert.equal(rejectedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); - assert.equal(rejectedEvent.data.error?.code, "field_not_granted"); - assert.match(rejectedEvent.data.error?.message || "", REGEXP_155); + const rejectedEvent = timeline.data.find( + (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId + ); + assert.ok( + rejectedEvent, + "grant timeline should include query.rejected for rejected view-based record-list reads" + ); + assert.equal(rejectedEvent.trace_id, rejectedTraceId); + assert.equal(rejectedEvent.stream_id, "top_artists"); + assert.equal(rejectedEvent.data.query_shape, "record_list"); + assert.equal(rejectedEvent.data.requested_view, "full"); + assert.equal(rejectedEvent.data.source?.kind, "connector"); + assert.equal(rejectedEvent.data.source?.id, SPOTIFY_SOURCE_ID); + assert.equal(rejectedEvent.data.error?.code, "invalid_request"); + assert.match(rejectedEvent.data.error?.message || "", REGEXP_155); - const servedEvent = timeline.data.find( - (event) => event.event_type === "disclosure.served" && event.object_id === rejectedRequestId - ); - assert.equal( - servedEvent, - undefined, - "rejected view-based record-list reads should not produce disclosure.served" - ); - }); - } - ); + const servedEvent = timeline.data.find( + (event) => event.event_type === "disclosure.served" && event.object_id === rejectedRequestId + ); + assert.equal( + servedEvent, + undefined, + "rejected view-based record-list reads should not produce disclosure.served" + ); + }); + }); await t.test( "field-limited client grants project changes_since disclosures to the granted field subset", @@ -8039,7 +8204,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time using the basic top-artist subset", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [ { fields: ["id", "name", "genres"], @@ -8053,7 +8218,7 @@ test("PDPP reference implementation integration", async (t) => { { headers: { Authorization: `Bearer ${approved.token}` } } ); assert.equal(baseline.status, 200); - const firstRecord = baseline.body.data?.[0]; + const [firstRecord] = baseline.body.data; assert.ok(firstRecord, "expected at least one granted record in the baseline changes_since response"); assert.deepEqual(Object.keys(firstRecord.data || {}).sort(), ["genres", "id", "name"]); assert.ok(!("popularity" in (firstRecord.data || {}))); @@ -8151,7 +8316,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time using the basic top-artist subset", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [ { fields: ["id", "name", "genres"], @@ -8164,14 +8329,14 @@ test("PDPP reference implementation integration", async (t) => { `${rsUrl}/v1/streams/top_artists/records?changes_since=${encodeURIComponent(Buffer.from(JSON.stringify({ kind: "changes_since", version: 0 })).toString("base64"))}&filter[popularity]=96`, { headers: { Authorization: `Bearer ${approved.token}` } } ); - assert.equal(rejectedResp.status, 403); + assert.equal(rejectedResp.status, 400); const rejectedRequestId = rejectedResp.headers.get("Request-Id"); const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(rejectedRequestId?.startsWith("req_")); assert.ok(rejectedTraceId?.startsWith("trc_")); const rejectedBody = parseErrorResponse(await rejectedResp.json()); - assert.equal(rejectedBody.error.code, "field_not_granted"); - assert.match(rejectedBody.error.message, REGEXP_156); + assert.equal(rejectedBody.error.code, "invalid_request"); + assert.match(rejectedBody.error.message, CLIENT_FILTER_ERROR); const { body: timeline } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); const queryReceivedEvent = timeline.data.find( @@ -8186,7 +8351,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(queryReceivedEvent.data.query_shape, "record_list"); assert.equal(queryReceivedEvent.data.has_changes_since, true); assert.equal(queryReceivedEvent.data.source?.kind, "connector"); - assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_SOURCE_ID); const rejectedEvent = timeline.data.find( (event) => event.event_type === "query.rejected" && event.object_id === rejectedRequestId @@ -8200,9 +8365,9 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(rejectedEvent.data.query_shape, "record_list"); assert.equal(rejectedEvent.data.has_changes_since, true); assert.equal(rejectedEvent.data.source?.kind, "connector"); - assert.equal(rejectedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); - assert.equal(rejectedEvent.data.error?.code, "field_not_granted"); - assert.match(rejectedEvent.data.error?.message || "", REGEXP_157); + assert.equal(rejectedEvent.data.source?.id, SPOTIFY_SOURCE_ID); + assert.equal(rejectedEvent.data.error?.code, "invalid_request"); + assert.match(rejectedEvent.data.error?.message || "", CLIENT_FILTER_ERROR); const servedEvent = timeline.data.find( (event) => event.event_type === "disclosure.served" && event.object_id === rejectedRequestId @@ -8340,7 +8505,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); @@ -8468,7 +8633,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Recommend concerts using only one permitted artist change stream", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [ { name: "top_artists", @@ -8511,7 +8676,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(queryReceivedEvent.data.query_shape, "record_list"); assert.equal(queryReceivedEvent.data.has_changes_since, true); assert.equal(queryReceivedEvent.data.source?.kind, "connector"); - assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(queryReceivedEvent.data.source?.id, SPOTIFY_SOURCE_ID); const servedEvent = timeline.data.find( (event) => event.event_type === "disclosure.served" && event.object_id === requestId @@ -8524,7 +8689,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(servedEvent.data.has_more, false); assert.equal(servedEvent.data.has_next_changes_since, true); assert.equal(servedEvent.data.source?.kind, "connector"); - assert.equal(servedEvent.data.source?.id, SPOTIFY_CONNECTOR_KEY); + assert.equal(servedEvent.data.source?.id, SPOTIFY_SOURCE_ID); }); } ); @@ -8541,7 +8706,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Maintain a concert-recommendation profile over time", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); @@ -8628,7 +8793,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "One-time recommendation bootstrap", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); @@ -8655,19 +8820,19 @@ test("PDPP reference implementation integration", async (t) => { // B1 HTTP proof: single_use consumption enforcement. // The grant is marked consumed atomically on first token issuance. // Any subsequent call to issueToken with the same grant_id MUST throw - // with code 'grant_consumed' — the grant cannot be re-exchanged. + // with code 'grant_consumed'; the grant cannot be re-exchanged. await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { const ownerToken = await issueOwnerToken(asUrl, "u1"); await seedSpotify(rsUrl, spotifyManifest, ownerToken); - // Step 1: issue the single_use grant — first token issuance happens + // Step 1: issue the single_use grant. First token issuance happens // inside approveGrant (POST /consent/approve) and marks it consumed. const approved = await approveGrant(asUrl, "u1", { access_mode: "single_use", client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "One-time recommendation bootstrap", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); assert.ok(approved.token, "first token was issued"); @@ -8687,16 +8852,35 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(grantRow.access_mode, "single_use"); assert.equal(grantRow.consumed, 1, "grant is marked consumed after first token issuance"); - // Step 4: attempt a second token issuance on the same grant — MUST fail. + const persistedBinding = getDb() + .prepare(` + SELECT client_id, expires_at, subject_id + FROM grants + WHERE grant_id = ? + `) + .get(approved.grant.grant_id); + assert.ok(persistedBinding, "persisted grant binding exists"); + + // Step 4: attempt a second token issuance on the same grant. It MUST fail. // This is the enforcement proof: grant_consumed, not a generic error. await assert.rejects( () => - issueToken(approved.grant.grant_id, "u1", "concert_recommendation_app", null, { - source: "test_second_issuance", - }), + issueToken( + approved.grant.grant_id, + requireString(persistedBinding.subject_id, "persisted grant subject"), + requireString(persistedBinding.client_id, "persisted grant client"), + requireNullableString(persistedBinding.expires_at, "persisted grant expires_at"), + { + source: "test_second_issuance", + } + ), (err: unknown) => { const error = requireJsonRecord(err, "single-use token error"); - assert.equal(error.code, "grant_consumed", "error code is grant_consumed"); + assert.equal( + error.code, + "grant_consumed", + `error code is grant_consumed: ${requireString(error.message, "single-use token error.message")}` + ); assert.match(requireString(error.message, "single-use token error.message"), REGEXP_161); return true; }, @@ -8717,7 +8901,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Ongoing concert recommendation assistant", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); assert.equal(approved.grant.access_mode, "continuous"); @@ -8751,6 +8935,29 @@ test("PDPP reference implementation integration", async (t) => { display_name: "Cursor expiry fixture", protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: false } } }, + source_declaration: { + declaration_version: "cursor-expiry-fixture.v1", + display: { name: "Cursor expiry fixture" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation" }, + source: { id: "https://registry.pdpp.dev/connectors/cursor-expiry-fixture", kind: "connector" }, + streams: [ + { + name: "events", + primary_key: ["id"], + schema: { + properties: { + id: { type: "string" }, + value: { type: "string" }, + }, + required: ["id", "value"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + ], + }, streams: [ { name: "events", @@ -8774,6 +8981,7 @@ test("PDPP reference implementation integration", async (t) => { method: "POST", }); assert.equal(registerResp.status, 201); + await seedDefaultGrantInstance(cursorManifest.connector_id, "u1", "Cursor expiry fixture"); const ownerToken = await issueOwnerToken(asUrl, "u1"); const initial = await fetch( `${rsUrl}/v1/ingest/events?connector_id=${encodeURIComponent(cursorManifest.connector_id)}`, @@ -8797,7 +9005,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Incremental sync with cursor expiry", - source: { id: cursorManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(cursorManifest.connector_id), kind: "connector" }, streams: [{ name: "events" }], }); @@ -8856,7 +9064,7 @@ test("PDPP reference implementation integration", async (t) => { client_id: "concert_recommendation_app", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Revocation test", - source: { id: spotifyManifest.connector_id, kind: "connector" }, + source: { id: sourceIdForConnectorId(spotifyManifest.connector_id), kind: "connector" }, streams: [{ name: "top_artists", view: "basic" }], }); diff --git a/reference-implementation/test/persisted-authorization-state-boundary.test.ts b/reference-implementation/test/persisted-authorization-state-boundary.test.ts new file mode 100644 index 000000000..fe2ae9bb9 --- /dev/null +++ b/reference-implementation/test/persisted-authorization-state-boundary.test.ts @@ -0,0 +1,102 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { requirePersistedGrantState } from "../server/auth.ts"; +import { closeDb, getDb } from "../server/db.ts"; +import { startServer } from "../server/index.ts"; +import { introspectionHeaders } from "./helpers/introspection.ts"; +import { TEST_RS_INTROSPECTION_CREDENTIALS } from "./helpers/introspection-test-credentials.ts"; + +const TEST_DIR = dirname(fileURLToPath(import.meta.url)); +const V01_LEGACY_BYTES = readFileSync(join(TEST_DIR, "seam-spike/fixtures/pr89/legacy-grant-v01.bytes"), "utf8").trim(); + +type TestServer = Awaited<ReturnType<typeof startServer>> & { + asServer: { close: (callback: () => void) => void; closeAllConnections: () => void }; + rsServer: { close: (callback: () => void) => void; closeAllConnections: () => void }; +}; + +async function closeServer(server: TestServer): Promise<void> { + server.asServer.closeAllConnections(); + server.rsServer.closeAllConnections(); + await Promise.allSettled([ + new Promise<void>((resolve) => server.asServer.close(resolve)), + new Promise<void>((resolve) => server.rsServer.close(resolve)), + ]); + closeDb(); +} + +function errorCode(body: unknown): string | undefined { + if (!(body && typeof body === "object" && "error" in body)) { + return; + } + const { error } = body as { error?: unknown }; + if (error && typeof error === "object" && "code" in error) { + return (error as { code?: string }).code; + } + return typeof error === "string" ? error : undefined; +} + +test("pre-contract persisted bytes are rejected by the current grant reader", () => { + assert.throws( + () => requirePersistedGrantState({ grant_json: V01_LEGACY_BYTES, storage_binding_json: null }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as Error & { code?: string }).code, "authorization_state.unsupported_legacy_shape"); + return true; + } + ); +}); + +test("legacy persisted grant state fails before the SQLite RS route", async () => { + const server = (await startServer({ + asPort: 0, + dbPath: ":memory:", + introspectionCallerCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + quiet: true, + rsIntrospectionCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + rsPort: 0, + })) as TestServer; + const asUrl = `http://localhost:${server.asPort}`; + const rsUrl = `http://localhost:${server.rsPort}`; + const token = "tok_legacy_authorization_state"; + const db = getDb(); + db.prepare( + `INSERT INTO grants( + grant_id, subject_id, client_id, storage_binding_json, grant_json, + access_mode, status, consumed, issued_at + ) VALUES (?, ?, ?, NULL, ?, 'continuous', 'active', FALSE, ?)` + ).run("grt_legacy", "owner_local", "legacy_client", V01_LEGACY_BYTES, "2026-08-11T12:00:00Z"); + db.prepare( + `INSERT INTO tokens(token_id, grant_id, subject_id, client_id, token_kind, expires_at, revoked) + VALUES (?, ?, ?, ?, 'client', NULL, FALSE)` + ).run(token, "grt_legacy", "owner_local", "legacy_client"); + + try { + const introspection = await fetch(`${asUrl}/introspect`, { + body: new URLSearchParams({ token }).toString(), + headers: introspectionHeaders("application/x-www-form-urlencoded"), + method: "POST", + }); + assert.equal(introspection.status, 200); + const introspectionBody = (await introspection.json()) as Record<string, unknown>; + assert.equal(introspectionBody.active, false); + assert.equal(introspectionBody.aud, rsUrl); + assert.equal(introspectionBody.client_id, "legacy_client"); + assert.equal(introspectionBody.grant_id, "grt_legacy"); + assert.equal(introspectionBody.inactive_reason, "authorization_state.unsupported_legacy_shape"); + assert.equal(introspectionBody.subject_id, "owner_local"); + assert.equal(new URL(String(introspectionBody.iss)).port, String(server.asPort)); + + const route = await fetch(`${rsUrl}/v1/schema`, { headers: { Authorization: `Bearer ${token}` } }); + assert.equal(route.status, 401); + assert.equal(errorCode(await route.json()), "authorization_state.unsupported_legacy_shape"); + } finally { + await closeServer(server); + } +}); diff --git a/reference-implementation/test/polyfill-manifest-reconcile-invalidation.test.ts b/reference-implementation/test/polyfill-manifest-reconcile-invalidation.test.ts index 37e4d04f4..58dab9506 100644 --- a/reference-implementation/test/polyfill-manifest-reconcile-invalidation.test.ts +++ b/reference-implementation/test/polyfill-manifest-reconcile-invalidation.test.ts @@ -70,6 +70,7 @@ function referenceFixtureManifest(overrides: Partial<Manifest> = {}): Manifest { connector_id: CONNECTOR_ID, connector_key: CONNECTOR_ID, display_name: "Seed flip fixture (reference shape)", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ @@ -102,6 +103,7 @@ function shippedPolyfillManifest(overrides: Partial<Manifest> = {}): Manifest { connector_id: CONNECTOR_ID, connector_key: CONNECTOR_ID, display_name: "Seed flip fixture (polyfill shape)", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ @@ -435,7 +437,7 @@ test( ); test( - "reconciliation does not delete records when the persisted manifest already matches the shipped manifest", + "reconciliation preserves records when the persisted manifest content matches the shipped manifest", withTmpDb(async ({ dir }) => { await registerConnector(shippedPolyfillManifest()); await ingestRecord(CONNECTOR_ID, { @@ -458,8 +460,12 @@ test( manifestsDir, referenceFixturesDir, }); - - assert.equal(summary.unchanged, 1, "reconciliation reports the manifest as unchanged"); + // Persisted rows now carry the generated SourceDeclaration snapshot, so + // byte comparison with the shipped legacy fixture remains an update even + // when the operational manifest content is unchanged. The update must + // still preserve records; a future storage-normalization fix can tighten + // this back to `unchanged` without changing the data-safety assertion. + assert.equal(summary.updated, 1, "reconciliation refreshes the derived declaration snapshot"); assert.equal(summary.invalidatedConnectors, 0, "no invalidation when fingerprints match"); assert.equal(summary.invalidatedRecords, 0, "no records counted as invalidated"); assert.equal(recordCount(CONNECTOR_ID), 1, "records survive a no-op reconciliation"); diff --git a/reference-implementation/test/postgres-expand-hydration.test.ts b/reference-implementation/test/postgres-expand-hydration.test.ts index 527ceece3..c36ddbe9c 100644 --- a/reference-implementation/test/postgres-expand-hydration.test.ts +++ b/reference-implementation/test/postgres-expand-hydration.test.ts @@ -419,17 +419,18 @@ if (POSTGRES_URL) { ); }); - await t.test("child grant time_range narrows expansion children in SQL", async () => { + await t.test("child grant time_constraint narrows expansion children in SQL", async () => { // play_1=2026-02-02, play_2=2026-02-03, play_3=2026-02-04, play_4=2026-02-05. // Narrow the child grant to [2026-02-03, 2026-02-05) → play_2 and play_3 // only for track_1; track_2 (play_4 at 2026-02-05) is `until`-excluded. - const grantWithTimeRange = { + const grantWithTimeConstraint = { streams: [ { fields: ["id", "name", "saved_at"], name: parentStream }, { fields: ["id", "track_id", "played_at"], name: childStream, - time_range: { + time_constraint: { + field: "played_at", since: "2026-02-03T00:00:00Z", until: "2026-02-05T00:00:00Z", }, @@ -439,7 +440,7 @@ if (POSTGRES_URL) { const result = await queryRecords( connectorId, parentStream, - grantWithTimeRange, + grantWithTimeConstraint, { expand: "recently_played", order: "asc" }, manifest ); @@ -449,7 +450,7 @@ if (POSTGRES_URL) { assert.deepEqual( track1.expanded.recently_played.data.map((c: { id: string }) => c.id), ["play_2", "play_3"], - "only children inside the grant time_range should appear" + "only children inside the frozen grant time_constraint should appear" ); assert.equal(track1.expanded.recently_played.has_more, false); diff --git a/reference-implementation/test/postgres-records-filter-sql.test.ts b/reference-implementation/test/postgres-records-filter-sql.test.ts index 9376a83b8..0ff2a597a 100644 --- a/reference-implementation/test/postgres-records-filter-sql.test.ts +++ b/reference-implementation/test/postgres-records-filter-sql.test.ts @@ -4,12 +4,20 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { __buildPostgresFilterClauseForTest } from "../server/postgres-records.ts"; +import { + __buildPostgresFilterClauseForTest, + __buildPostgresGrantVisibilityForTest, +} from "../server/postgres-records.ts"; const RE_AMOUNT_GTE = /\(record_json->>'amount'\)::numeric >= \$1::numeric/; const RE_AMOUNT_LTE = /\(record_json->>'amount'\)::numeric <= \$2::numeric/; const RE_DATE_GTE = /\(record_json->>'date'\)::date >= \$1::date/; const RE_DATE_LTE = /\(record_json->>'date'\)::date <= \$2::date/; +const RE_GRANT_TIME_FIELD = /record_json->>'occurred_at'/; +const RE_GRANT_TIME_PARSE_GUARD = /pg_input_is_valid/; +const RE_GRANT_TIME_SINCE = />= \$1::timestamptz/; +const RE_GRANT_TIME_UNTIL = /< \$2::timestamptz/; +const RE_HYPHENATED_GRANT_TIME_FIELD = /record_json->>'event-time'/; const RE_UNSUPPORTED_OP = /Unsupported range operator 'between'/; const transactionsStream = { @@ -45,6 +53,15 @@ test("Postgres records SQL casts declared amount ranges numerically, not as text assert.deepEqual(params, ["0", "-50000"]); }); +test("Postgres visibility SQL supports a hyphenated frozen grant field", () => { + const { whereParts } = __buildPostgresGrantVisibilityForTest({ + name: "transactions", + time_constraint: { field: "event-time", since: "2026-01-01T00:00:00Z" }, + }); + + assert.match(whereParts.join(" AND "), RE_HYPHENATED_GRANT_TIME_FIELD); +}); + test("Postgres records SQL casts declared date ranges as dates", () => { const { clause, params } = __buildPostgresFilterClauseForTest( { date: { gte: "2026-05-01", lte: "2026-05-05" } }, @@ -63,3 +80,21 @@ test("Postgres records SQL builder rejects unsupported range operators before SQ RE_UNSUPPORTED_OP ); }); + +test("Postgres visibility SQL uses the frozen grant field with half-open bounds and parseability guard", () => { + const { params, whereParts } = __buildPostgresGrantVisibilityForTest({ + name: "transactions", + time_constraint: { + field: "occurred_at", + since: "2026-01-01T00:00:00Z", + until: "2026-02-01T00:00:00Z", + }, + }); + const clause = whereParts.join(" AND "); + + assert.match(clause, RE_GRANT_TIME_FIELD); + assert.match(clause, RE_GRANT_TIME_PARSE_GUARD); + assert.match(clause, RE_GRANT_TIME_SINCE); + assert.match(clause, RE_GRANT_TIME_UNTIL); + assert.deepEqual(params, ["2026-01-01T00:00:00.000Z", "2026-02-01T00:00:00.000Z"]); +}); diff --git a/reference-implementation/test/postgres-runtime-storage.test.ts b/reference-implementation/test/postgres-runtime-storage.test.ts index 6996fa73b..a3651bae9 100644 --- a/reference-implementation/test/postgres-runtime-storage.test.ts +++ b/reference-implementation/test/postgres-runtime-storage.test.ts @@ -490,7 +490,7 @@ if (POSTGRES_URL) { protocol_version: "0.1.0", streams: [ { - consent_time_field: "created_at", + consent_time_field: "event-time", cursor_field: "created_at", name: stream, primary_key: ["id"], @@ -502,7 +502,9 @@ if (POSTGRES_URL) { schema: { properties: { created_at: { format: "date-time", type: "string" }, + "event-time": { format: "date-time", type: "string" }, id: { type: "string" }, + mutable_time: { format: "date-time", type: "string" }, text: { type: "string" }, }, required: ["id"], @@ -514,7 +516,14 @@ if (POSTGRES_URL) { }; const grant = { source: { id: connectorId, kind: "connector" }, - streams: [{ fields: ["id", "text"], name: stream }], + streams: [ + { + fields: ["id", "text"], + instance_ids: [connectorInstanceId], + name: stream, + time_constraint: { field: "created_at", since: "2026-06-02T00:00:00.000Z" }, + }, + ], }; const tokenInfo = { client_id: "cl_pg_lexical_backfill", @@ -552,8 +561,18 @@ if (POSTGRES_URL) { connectorId, connectorInstanceId, stream, - JSON.stringify({ id: "msg-1", text: "Redactable alpha historical row" }), - JSON.stringify({ id: "msg-2", text: "Redactable beta historical row" }), + JSON.stringify({ + created_at: "2026-06-01T00:00:00.000Z", + id: "msg-1", + mutable_time: "2026-06-03T00:00:00.000Z", + text: "Redactable alpha historical row", + }), + JSON.stringify({ + created_at: "2026-06-02T00:00:00.000Z", + id: "msg-2", + mutable_time: "2026-06-03T00:00:00.000Z", + text: "Redactable beta historical row", + }), "2026-06-01T00:00:00.000Z", ] ); @@ -602,7 +621,7 @@ if (POSTGRES_URL) { ); assert.deepEqual( page.envelope.data.map((hit) => hit.record_key).sort((a, b) => a.localeCompare(b)), - ["msg-1", "msg-2"] + ["msg-2"] ); } finally { await postgresQuery("DELETE FROM lexical_search_index WHERE connector_id = $1", [connectorId]); @@ -625,7 +644,7 @@ if (POSTGRES_URL) { protocol_version: "0.1.0", streams: [ { - consent_time_field: "created_at", + consent_time_field: "mutable_time", cursor_field: "created_at", name: stream, primary_key: ["id"], @@ -636,6 +655,7 @@ if (POSTGRES_URL) { properties: { created_at: { format: "date-time", type: "string" }, id: { type: "string" }, + mutable_time: { format: "date-time", type: "string" }, title: { type: "string" }, }, required: ["id"], @@ -645,7 +665,7 @@ if (POSTGRES_URL) { ], version: "1.0.0", }; - const fields = ["id", "title", "created_at"]; + const fields = ["event-time", "id", "title"]; const fullGrant = { streams: [{ fields, name: stream }] }; const resourceGrant = { streams: [{ fields, name: stream, resources: ["a"] }] }; const timeGrant = { @@ -653,7 +673,8 @@ if (POSTGRES_URL) { { fields, name: stream, - time_range: { + time_constraint: { + field: "event-time", since: "2026-04-02T00:00:00.000Z", until: "2026-04-03T00:00:00.000Z", }, @@ -668,7 +689,9 @@ if (POSTGRES_URL) { await ingestRecord(connectorId, { data: { created_at: "2026-04-01T00:00:00.000Z", + "event-time": "2026-04-01T00:00:00.000Z", id: "a", + mutable_time: "2026-04-02T12:00:00.000Z", title: "Alpha launch", }, key: "a", @@ -677,7 +700,9 @@ if (POSTGRES_URL) { await ingestRecord(connectorId, { data: { created_at: "2026-04-02T00:00:00.000Z", + "event-time": "2026-04-02T00:00:00.000Z", id: "b", + mutable_time: "2026-04-02T12:00:00.000Z", title: "Beta proof", }, key: "b", @@ -871,7 +896,8 @@ if (POSTGRES_URL) { test("postgres runtime storage covers records, blobs, spine, lexical, and semantic fallback", async () => { const suffix = `${Date.now()}_${Math.floor(Math.random() * 1e6)}`; - const connectorId = `pg_runtime_${suffix}`; + const connectorId = `pg-runtime-${suffix.replaceAll("_", "-")}`; + const sourceId = `https://registry.pdpp.org/connectors/${connectorId}`; const clientId = `pg_client_${suffix}`; const ownerSubjectId = `pg_owner_${suffix}`; const stream = "events"; @@ -891,8 +917,43 @@ if (POSTGRES_URL) { const manifest = { capabilities: { human_interaction: [] }, connector_id: connectorId, + connector_key: connectorId, display_name: "Postgres Runtime Test", + manifest_uri: sourceId, protocol_version: "0.1.0", + source_declaration: { + declaration_version: `postgres-runtime.${suffix}.v1`, + display: { name: "Postgres Runtime Test" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation/tests" }, + source: { id: sourceId, kind: "connector" }, + streams: [ + { + consent_time_field: "created_at", + cursor_field: "created_at", + name: stream, + primary_key: ["id"], + query: { + search: { + lexical_fields: ["title", "body"], + semantic_fields: ["body"], + }, + }, + schema: { + properties: { + body: { type: "string" }, + created_at: { format: "date-time", type: "string" }, + id: { type: "string" }, + title: { type: "string" }, + }, + required: ["id"], + type: "object", + }, + selection: { fields: true, resources: false }, + semantics: "mutable_state", + }, + ], + }, streams: [ { consent_time_field: "created_at", @@ -1119,13 +1180,32 @@ if (POSTGRES_URL) { // biome-ignore lint/suspicious/noUnnecessaryConditions: assertion retains its defensive runtime boundary assert.ok((retainedConnections[0]?.total_retained_bytes ?? 0) > 0); + const approvalInstanceStore = createPostgresConnectorInstanceStore(); + const approvalInstanceNow = new Date().toISOString(); + const defaultConnectorInstanceId = makeDefaultAccountConnectorInstanceId( + OWNER_AUTH_DEFAULT_SUBJECT_ID, + connectorId + ); + await approvalInstanceStore.upsert({ + connectorId, + connectorInstanceId: defaultConnectorInstanceId, + createdAt: approvalInstanceNow, + displayName: "Postgres Runtime Default Account", + ownerSubjectId, + sourceBinding: { fixture: defaultConnectorInstanceId }, + sourceBindingKey: defaultConnectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: approvalInstanceNow, + }); + const grantInit = await initiateGrant({ authorization_details: [ { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Postgres runtime storage coverage", - source: { id: connectorId, kind: "connector" }, + source: { id: sourceId, kind: "connector" }, streams: [{ fields: ["id", "title", "body", "created_at"], name: stream }], type: "https://pdpp.dev/data-access", }, @@ -1153,7 +1233,7 @@ if (POSTGRES_URL) { approval.kind === "consent" && approval.client_id === clientId && approval.grant_preview?.source?.kind === "connector" && - approval.grant_preview?.source?.id === connectorId + approval.grant_preview?.source?.id === sourceId ) ); @@ -1161,9 +1241,16 @@ if (POSTGRES_URL) { if (!approvedOwnerSubjectId) { throw new Error("owner subject must be configured"); } - const approved = await approveGrant(deviceCode, approvedOwnerSubjectId); + const reviewed = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: approvedOwnerSubjectId }); + assert.ok(typeof reviewed?.reviewRevision === "string"); + const approved = await approveGrant(deviceCode, approvedOwnerSubjectId, { + approval_review_revision: reviewed?.reviewRevision, + }); issuedGrantId = (approved.grant as { grant_id: string }).grant_id; - assert.deepEqual((approved.grant as { source?: unknown }).source, { id: connectorId, kind: "connector" }); + assert.deepEqual((approved.grant as { source?: unknown }).source, { id: sourceId, kind: "connector" }); + assert.deepEqual((approved.grant as { streams: Array<{ instance_ids?: string[] }> }).streams[0]?.instance_ids, [ + defaultConnectorInstanceId, + ]); const tokenInfo = castIntrospect(await introspect(approved.token)); assert.equal(tokenInfo.active, true); assert.equal(tokenInfo.grant_id, (approved.grant as { grant_id: string }).grant_id); @@ -1324,30 +1411,8 @@ if (POSTGRES_URL) { }); assert.deepEqual([...new Set(accountALexicalHits.map((row) => row.record_key))], ["shared"]); - // Register the default connector instance for this owner so the client-mode - // search fan-in (resolveClientBindings -> resolveFanInBindings -> - // listActiveBindingsForGrant) can discover an active binding. ingestRecord - // writes to the records table using makeDefaultAccountConnectorInstanceId - // but never inserts a connector_instances row; that registration belongs here - // in the test setup, not in the ingest path. - const defaultConnectorInstanceId = makeDefaultAccountConnectorInstanceId( - OWNER_AUTH_DEFAULT_SUBJECT_ID, - connectorId - ); - const instanceStore = createPostgresConnectorInstanceStore(); - const instanceNow = new Date().toISOString(); - await instanceStore.upsert({ - connectorId, - connectorInstanceId: defaultConnectorInstanceId, - createdAt: instanceNow, - displayName: connectorId, - ownerSubjectId, - sourceBinding: {}, - sourceKind: "account", - status: "active", - updatedAt: instanceNow, - }); - + // The grant froze the default instance before approval, so client search + // resolves the same namespace that contains records a and b. const searchDeps = { buildOwnerReadGrantForManifest: () => grant, resolveGrantManifest: async () => ({ manifest }), diff --git a/reference-implementation/test/postgres-temp-database-helper.test.js b/reference-implementation/test/postgres-temp-database-helper.test.js index 73b8df1f7..99d3232d0 100644 --- a/reference-implementation/test/postgres-temp-database-helper.test.js +++ b/reference-implementation/test/postgres-temp-database-helper.test.js @@ -3,11 +3,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import pg from "pg"; +import { Pool } from "pg"; -import { withTemporaryPostgresDatabase } from "./helpers/postgres-temp-database.js"; +import { withTemporaryPostgresDatabase } from "./helpers/postgres-temp-database.ts"; -const { Pool } = pg; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; function adminUrl(connectionString) { @@ -16,10 +15,10 @@ function adminUrl(connectionString) { return url.toString(); } -async function assertDatabaseDropped(databaseName) { +async function assertDatabaseDropped(targetDatabaseName) { const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); try { - const result = await admin.query("SELECT 1 FROM pg_database WHERE datname = $1", [databaseName]); + const result = await admin.query("SELECT 1 FROM pg_database WHERE datname = $1", [targetDatabaseName]); assert.equal(result.rowCount, 0); } finally { await admin.end(); @@ -62,7 +61,7 @@ if (POSTGRES_URL) { const callbackError = new Error("callback failed"); await assert.rejects( - withTemporaryPostgresDatabase({ connectionString: POSTGRES_URL, databaseName: temporaryDatabase }, async () => { + withTemporaryPostgresDatabase({ connectionString: POSTGRES_URL, databaseName: temporaryDatabase }, () => { throw callbackError; }), (error) => error === callbackError @@ -78,13 +77,13 @@ if (POSTGRES_URL) { await assert.rejects( withTemporaryPostgresDatabase( { - closeConnections: async () => { + closeConnections: () => { throw cleanupError; }, connectionString: POSTGRES_URL, databaseName: temporaryDatabase, }, - async () => {} + () => undefined ), (error) => error === cleanupError ); @@ -100,13 +99,13 @@ if (POSTGRES_URL) { await assert.rejects( withTemporaryPostgresDatabase( { - closeConnections: async () => { + closeConnections: () => { throw cleanupError; }, connectionString: POSTGRES_URL, databaseName: temporaryDatabase, }, - async () => { + () => { throw callbackError; } ), @@ -120,5 +119,5 @@ if (POSTGRES_URL) { await assertDatabaseDropped(temporaryDatabase); }); } else { - test("temporary Postgres database helper (skipped: PDPP_TEST_POSTGRES_URL unset)", { skip: true }, () => {}); + test("temporary Postgres database helper (skipped: PDPP_TEST_POSTGRES_URL unset)", { skip: true }, () => undefined); } diff --git a/reference-implementation/test/provider-auth-lifecycle.test.ts b/reference-implementation/test/provider-auth-lifecycle.test.ts index 1b72eb3cc..1b6b92629 100644 --- a/reference-implementation/test/provider-auth-lifecycle.test.ts +++ b/reference-implementation/test/provider-auth-lifecycle.test.ts @@ -97,6 +97,8 @@ const TEST_PROVIDER_MANIFEST = { connector_id: "test_provider", connector_key: "test_provider", display_name: "Test Provider", + manifest_uri: "https://sources.example/test_provider", + protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ { @@ -107,6 +109,8 @@ const TEST_PROVIDER_MANIFEST = { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -118,6 +122,8 @@ const NON_OAUTH_MANIFEST = { connector_id: "plain_api", connector_key: "plain_api", display_name: "Plain API", + manifest_uri: "https://sources.example/plain_api", + protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ { @@ -128,6 +134,8 @@ const NON_OAUTH_MANIFEST = { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -232,11 +240,12 @@ async function withServer( const rsUrl = `http://localhost:${server.rsPort}`; try { // Register the test provider connector. - await fetch(`${asUrl}/connectors`, { + const registration = await fetch(`${asUrl}/connectors`, { body: JSON.stringify(TEST_PROVIDER_MANIFEST), headers: { "Content-Type": "application/json" }, method: "POST", }); + assert.equal(registration.status, 201, `provider test fixture registration (${await registration.text()})`); await fn({ asUrl, rsUrl, server }); } finally { await closeServer(server); diff --git a/reference-implementation/test/provider-metadata.test.ts b/reference-implementation/test/provider-metadata.test.ts index 40fcbec16..731d16546 100644 --- a/reference-implementation/test/provider-metadata.test.ts +++ b/reference-implementation/test/provider-metadata.test.ts @@ -13,6 +13,29 @@ import { resolvePublicUrl, resolveSiblingPublicUrl } from "../server/metadata.ts import { PDPP_REFERENCE_REVISION_HEADER } from "../server/reference-revision.ts"; const TEST_DCR_INITIAL_ACCESS_TOKEN = "pdpp-reference-test-initial-access-token"; +const NORTHSTAR_PROVIDER_ID = "https://northstar.example/pdpp"; +const NORTHSTAR_STORAGE_CONNECTOR_ID = "northstar_hr_native"; +const NORTHSTAR_SOURCE_DECLARATION = { + declaration_version: "reference.native-config.northstar-hr.test", + display: { name: "Northstar HR" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation" }, + source: { id: NORTHSTAR_PROVIDER_ID, kind: "provider_native" }, + streams: [ + { + name: "pay_statements", + primary_key: ["statement_id"], + schema: { + properties: { statement_id: { type: "string" } }, + required: ["statement_id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + ], +}; + interface JsonObject { [key: string]: any; } @@ -1165,7 +1188,7 @@ test("explicit browser-facing public urls drive metadata, device verification, a access_mode: "single_use", purpose_code: "https://pdpp.dev/purpose/recommendation", purpose_description: "Review top artists", - retention: "P30D", + retention: { max_duration: "P30D", on_expiry: "delete" }, source: { id: "https://registry.pdpp.dev/connectors/spotify", kind: "connector" }, streams: [{ name: "top_artists" }], type: "https://pdpp.dev/data-access", @@ -1454,11 +1477,13 @@ test("provider metadata omits registration endpoint when dynamic registration is }); test("native provider metadata surfaces the native provider name", async () => { + const sourceDeclarationUri = "https://declarations.example.test/northstar.json"; const nativeManifest = { name: "Northstar HR", - provider_id: "northstar_hr", - storage_binding: { connector_id: "northstar_hr_native" }, - streams: [], + provider_id: NORTHSTAR_PROVIDER_ID, + source_declaration: NORTHSTAR_SOURCE_DECLARATION, + storage_binding: { connector_id: NORTHSTAR_STORAGE_CONNECTOR_ID }, + version: "0.1.0", }; const server = await startServer({ asPort: 0, @@ -1466,15 +1491,35 @@ test("native provider metadata surfaces the native provider name", async () => { nativeManifest, quiet: true, rsPort: 0, + sourceDeclarationUri, + trustedMetadataHosts: "northstar.example.test", }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; + const nativePublicHeaders = { + "x-forwarded-host": "northstar.example.test", + "x-forwarded-proto": "https", + }; try { - const protectedResource = await fetchJson(`${rsUrl}/.well-known/oauth-protected-resource`); + const protectedResource = await fetchJson(`${rsUrl}/.well-known/oauth-protected-resource`, { + headers: nativePublicHeaders, + }); assert.equal(protectedResource.status, 200); + assert.equal(protectedResource.body.resource, "https://northstar.example.test"); assert.equal(protectedResource.body.resource_name, "Northstar HR Resource Server"); - assert.deepEqual(protectedResource.body.authorization_servers, [asUrl]); + assert.deepEqual(protectedResource.body.authorization_servers, ["https://northstar.example.test"]); + assert.equal(protectedResource.body.pdpp_source_declaration_uri, sourceDeclarationUri); + + const hostedMcp = await fetchJson(`${rsUrl}/.well-known/oauth-protected-resource/mcp`, { + headers: nativePublicHeaders, + }); + assert.equal(hostedMcp.status, 200); + assert.equal( + "pdpp_source_declaration_uri" in hostedMcp.body, + false, + "the provider-native pointer must not be advertised for the separate hosted-MCP resource" + ); const authorizationServer = await fetchJson(`${asUrl}/.well-known/oauth-authorization-server`); assert.equal(authorizationServer.status, 200); @@ -1484,21 +1529,23 @@ test("native provider metadata surfaces the native provider name", async () => { } }); -test("native startup rejects manifests missing provider_id", async () => { +test("native startup rejects manifests missing SourceDeclaration source identity", async () => { + const { source: _source, ...sourceDeclarationWithoutSource } = NORTHSTAR_SOURCE_DECLARATION; await assert.rejects( startServer({ asPort: 0, dbPath: ":memory:", nativeManifest: { name: "Northstar HR", - storage_binding: { connector_id: "northstar_hr_native" }, - streams: [], + source_declaration: sourceDeclarationWithoutSource, + storage_binding: { connector_id: NORTHSTAR_STORAGE_CONNECTOR_ID }, + version: "0.1.0", }, quiet: true, rsPort: 0, }), // biome-ignore lint/performance/useTopLevelRegex: localized test assertion preserves its explicit contract. - /Native manifest must include provider_id/ + /Invalid SourceDeclaration/ ); }); @@ -1509,8 +1556,9 @@ test("native startup rejects manifests missing storage_binding.connector_id", as dbPath: ":memory:", nativeManifest: { name: "Northstar HR", - provider_id: "northstar_hr", - streams: [], + provider_id: NORTHSTAR_PROVIDER_ID, + source_declaration: NORTHSTAR_SOURCE_DECLARATION, + version: "0.1.0", }, quiet: true, rsPort: 0, @@ -1528,9 +1576,10 @@ test("native startup rejects manifests that include connector_id", async () => { nativeManifest: { connector_id: "https://registry.pdpp.dev/connectors/not-actually-native", name: "Northstar HR", - provider_id: "northstar_hr", - storage_binding: { connector_id: "northstar_hr_native" }, - streams: [], + provider_id: NORTHSTAR_PROVIDER_ID, + source_declaration: NORTHSTAR_SOURCE_DECLARATION, + storage_binding: { connector_id: NORTHSTAR_STORAGE_CONNECTOR_ID }, + version: "0.1.0", }, quiet: true, rsPort: 0, @@ -1547,12 +1596,13 @@ test("native startup rejects manifests whose storage_binding includes unsupporte dbPath: ":memory:", nativeManifest: { name: "Northstar HR", - provider_id: "northstar_hr", + provider_id: NORTHSTAR_PROVIDER_ID, + source_declaration: NORTHSTAR_SOURCE_DECLARATION, storage_binding: { - connector_id: "northstar_hr_native", + connector_id: NORTHSTAR_STORAGE_CONNECTOR_ID, debug_context: "should_not_be_accepted", }, - streams: [], + version: "0.1.0", }, quiet: true, rsPort: 0, @@ -1776,9 +1826,10 @@ test("pdpp_discovery_hints omits owner_polyfill_requires_source_kind_connector w // hybrid_pagination_supported). const nativeManifest = { name: "Northstar HR", - provider_id: "northstar_hr", - storage_binding: { connector_id: "northstar_hr_native" }, - streams: [], + provider_id: NORTHSTAR_PROVIDER_ID, + source_declaration: NORTHSTAR_SOURCE_DECLARATION, + storage_binding: { connector_id: NORTHSTAR_STORAGE_CONNECTOR_ID }, + version: "0.1.0", }; const server = await startServer({ asPort: 0, diff --git a/reference-implementation/test/public-read-connection-id-decoration.test.ts b/reference-implementation/test/public-read-connection-id-decoration.test.ts index 2640c18e7..6077ab1e8 100644 --- a/reference-implementation/test/public-read-connection-id-decoration.test.ts +++ b/reference-implementation/test/public-read-connection-id-decoration.test.ts @@ -41,6 +41,7 @@ const manifest: any = { capabilities: { human_interaction: [] }, connector_id: CONNECTOR_ID, display_name: "Decoration Test Connector", + manifest_uri: "https://registry.pdpp.org/connectors/connection-id-decoration", protocol_version: "0.1.0", streams: [ { @@ -60,6 +61,8 @@ const manifest: any = { required: ["id", "subject", "received_at"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", diff --git a/reference-implementation/test/purpose-code-uri-validation.test.ts b/reference-implementation/test/purpose-code-uri-validation.test.ts index c078cdd41..591d161d8 100644 --- a/reference-implementation/test/purpose-code-uri-validation.test.ts +++ b/reference-implementation/test/purpose-code-uri-validation.test.ts @@ -12,6 +12,8 @@ import { initiateGrant, registerConnector, registerDynamicClient } from "../serv import { initDb } from "../server/db.ts"; const TOP_LEVEL_REGEX_1 = /purpose_code/; +const CONNECTOR_ID = "demo"; +const SOURCE_ID = "https://registry.pdpp.dev/connectors/demo"; let registeredClientId: string | null = null; @@ -20,7 +22,23 @@ function isCodedError(error: unknown): error is Error & { code?: string } { } const MANIFEST = { - connector_id: "demo", + connector_id: CONNECTOR_ID, + source_declaration: { + declaration_version: "purpose-code-test.v1", + display: { name: "Purpose Code Test" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation" }, + source: { id: SOURCE_ID, kind: "connector" }, + streams: [ + { + name: "items", + primary_key: ["id"], + schema: { properties: { id: { type: "string" } }, type: "object" }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + ], + }, streams: [ { name: "items", @@ -39,7 +57,7 @@ function baseRequest(purposeCode: string): Record<string, unknown> { access_mode: "single_use", purpose_code: purposeCode, purpose_description: "purpose-code syntax coverage", - source: { id: "demo", kind: "connector" }, + source: { id: SOURCE_ID, kind: "connector" }, streams: [{ fields: ["id"], name: "items" }], type: "https://pdpp.dev/data-access", }, @@ -82,15 +100,15 @@ test("an UNKNOWN absolute-URI purpose_code is still accepted (registry is adviso assert.equal(out.ok, true, `unknown absolute URIs must not be rejected: ${JSON.stringify(out)}`); }); -test("a bare non-URI purpose_code is rejected with invalid_request", async () => { +test("a bare non-URI purpose_code is rejected with source.authorization_details_invalid", async () => { const out = await purposeCodeOutcome("analytics"); assert.equal(out.ok, false, "bare token must be rejected"); - assert.equal(out.code, "invalid_request"); + assert.equal(out.code, "source.authorization_details_invalid"); assert.match(out.message, TOP_LEVEL_REGEX_1); }); test("a dotted non-URI purpose_code is rejected", async () => { const out = await purposeCodeOutcome("assist.summarize"); assert.equal(out.ok, false); - assert.equal(out.code, "invalid_request"); + assert.equal(out.code, "source.authorization_details_invalid"); }); diff --git a/reference-implementation/test/query-contract.test.ts b/reference-implementation/test/query-contract.test.ts index 16546cfca..315144f88 100644 --- a/reference-implementation/test/query-contract.test.ts +++ b/reference-implementation/test/query-contract.test.ts @@ -177,17 +177,25 @@ async function startGrantRequest(asUrl: string, params: JsonObject) { }); } -// biome-ignore lint/suspicious/useAwait: async test doubles retain the Promise-returning dependency contract and its microtask timing. async function approveGrantRequest(asUrl: string, requestUri: string, subjectId = "owner_local") { - return fetchJson(`${asUrl}/consent/approve`, { + const review = await fetchJson(`${asUrl}/consent/review`, { body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + const reviewRevision = (review.body as Record<string, unknown>).approval_review_revision; + assert.equal(typeof reviewRevision, "string", "consent review must return approval_review_revision"); + return fetchJson(`${asUrl}/consent/approve`, { + body: JSON.stringify({ approval_review_revision: reviewRevision, request_uri: requestUri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); } async function approveGrant(asUrl: string, subjectId: string, params: JsonObject) { - const { body: initiate } = await startGrantRequest(asUrl, params); + const { body: initiate, status } = await startGrantRequest(asUrl, params); + assert.equal(status, 201, JSON.stringify(initiate)); const { body: approved } = await approveGrantRequest(asUrl, initiate.request_uri, subjectId); return approved; } @@ -374,23 +382,45 @@ async function seedSpotifyTopArtists( await seedSpotifyStream(rsUrl, ownerToken, connectorId, "top_artists", records); } -async function materializeSpotifyConnection(connectorId: string): Promise<void> { +async function materializeConnection({ + connectorId, + connectorInstanceId, + displayName, + ownerSubjectId = OWNER_AUTH_DEFAULT_SUBJECT_ID, +}: { + connectorId: string; + connectorInstanceId: string; + displayName: string; + ownerSubjectId?: string; +}): Promise<void> { const now = "2026-01-01T00:00:00.000Z"; await createSqliteConnectorInstanceStore().upsert({ // biome-ignore lint/style/noNonNullAssertion: the assertion follows an explicit test guard that proves fixture presence. connectorId: canonicalConnectorKey(connectorId)!, - connectorInstanceId: "cin_query_contract_spotify", + connectorInstanceId, createdAt: now, - displayName: "Spotify", - ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, - sourceBinding: { kind: "test_account", label: "query-contract-spotify" }, - sourceBindingKey: "query-contract-spotify", + displayName, + ownerSubjectId, + sourceBinding: { kind: "test_account", label: connectorInstanceId }, + sourceBindingKey: connectorInstanceId, sourceKind: "account", status: "active", updatedAt: now, }); } +async function materializeSpotifyConnection( + connectorId: string, + ownerSubjectId = OWNER_AUTH_DEFAULT_SUBJECT_ID +): Promise<void> { + await materializeConnection({ + connectorId, + connectorInstanceId: "cin_query_contract_spotify", + displayName: "Spotify", + ownerSubjectId, + }); +} + async function seedGmailStream( rsUrl: string, ownerToken: string, @@ -582,6 +612,7 @@ test("connector discovery lists owner-visible polyfill connectors without connec test("connector discovery scopes client tokens to the granted source and streams", async () => { await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { + await materializeSpotifyConnection(spotifyManifest.connector_id, "schema_discovery_owner"); const approved = await approveGrant(asUrl, "schema_discovery_owner", { access_mode: "continuous", client_id: "longview", @@ -670,6 +701,7 @@ test("schema discovery scopes a client token to its grant source and streams", a await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { const gmailManifest = readGmailManifest(); assert.equal((await registerConnectorManifest(asUrl, gmailManifest)).status, 201); + await materializeSpotifyConnection(spotifyManifest.connector_id, "schema_client_owner"); const approved = await approveGrant(asUrl, "schema_client_owner", { access_mode: "continuous", client_id: "longview", @@ -701,15 +733,13 @@ test("schema discovery scopes a client token to its grant source and streams", a // biome-ignore lint/style/useDestructuring: the property access names the fixture value at its point of use. const topArtists = connector.streams[0]; - // field-limited grant: granted fields are usable; ungranted fields are present but not usable. + // Client metadata is a closed grant projection. It exposes granted field + // names without importing current declaration capabilities. assert.equal(topArtists.field_capabilities.id.granted, true); assert.equal(topArtists.field_capabilities.name.granted, true); assert.equal(topArtists.field_capabilities.source_updated_at.granted, true); - assert.equal(topArtists.field_capabilities.source_updated_at.range_filter.usable, true); - assert.ok(topArtists.field_capabilities.popularity, "popularity field is enumerated"); - assert.equal(topArtists.field_capabilities.popularity.granted, false); - assert.equal(topArtists.field_capabilities.popularity.exact_filter.usable, false); - assert.equal(topArtists.field_capabilities.popularity.exact_filter.reason, "field_not_granted"); + assert.equal(topArtists.field_capabilities.source_updated_at.range_filter, undefined); + assert.equal(topArtists.field_capabilities.popularity, undefined); const serialized = JSON.stringify(body); assert.equal(serialized.includes(gmailManifest.connector_id), false, "must not leak other connectors"); @@ -820,8 +850,9 @@ test("stream metadata advertises lexical, semantic, and expansion capabilities f }); }); -test("stream metadata marks grant-limited field capabilities unusable for client tokens", async () => { +test("stream metadata projects only frozen grant fields for client tokens", async () => { await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { + await materializeSpotifyConnection(spotifyManifest.connector_id, "capability_limited_spotify_owner"); const approved = await approveGrant(asUrl, "capability_limited_spotify_owner", { access_mode: "continuous", client_id: "longview", @@ -838,43 +869,36 @@ test("stream metadata marks grant-limited field capabilities unusable for client assert.equal(status, 200); assert.equal(body.object, "stream_metadata"); - assert.ok( - body.schema?.properties?.source_updated_at, - "existing schema metadata should remain full source-level metadata" - ); - assert.deepEqual(body.query.range_filters.source_updated_at, ["gte", "gt", "lte", "lt"]); + assert.ok(body.schema?.properties?.source_updated_at, "granted field names remain visible"); + assert.deepEqual(body.query, {}); assert.equal(body.field_capabilities.name.granted, true); - assert.deepEqual(body.field_capabilities.name.exact_filter, { - declared: true, - usable: true, - }); + assert.equal(body.field_capabilities.name.exact_filter, undefined); assert.equal(body.field_capabilities.source_updated_at.granted, true); - assert.deepEqual(body.field_capabilities.source_updated_at.range_filter, { - declared: true, - operators: ["gte", "gt", "lte", "lt"], - usable: true, - }); - assert.deepEqual(body.field_capabilities.popularity.exact_filter, { - declared: true, - reason: "field_not_granted", - usable: false, - }); - assert.deepEqual(body.field_capabilities.popularity.aggregation.sum, { - declared: true, - reason: "field_not_granted", - usable: false, - }); + assert.equal(body.field_capabilities.source_updated_at.range_filter, undefined); + assert.equal(body.field_capabilities.popularity, undefined); const gmailManifest = readGmailManifest(); const registerResp = await registerConnectorManifest(asUrl, gmailManifest); assert.equal(registerResp.status, 201); + await materializeConnection({ + connectorId: gmailManifest.connector_id, + connectorInstanceId: "cin_query_contract_gmail", + displayName: "Gmail", + ownerSubjectId: "capability_limited_gmail_owner", + }); const gmailGrant = await approveGrant(asUrl, "capability_limited_gmail_owner", { access_mode: "continuous", client_id: "longview", connector_id: gmailManifest.connector_id, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "Plan message queries using a narrowed field set", - streams: [{ fields: ["id", "thread_id", "received_at", "subject"], name: "messages" }], + streams: [ + { + fields: ["id", "thread_id", "received_at", "subject"], + instance_ids: ["cin_query_contract_gmail"], + name: "messages", + }, + ], }); assert.ok(gmailGrant.token, `expected issued grant token, got ${JSON.stringify(gmailGrant)}`); @@ -883,41 +907,10 @@ test("stream metadata marks grant-limited field capabilities unusable for client }); assert.equal(gmailMetadata.status, 200); - assert.deepEqual(gmailMetadata.body.field_capabilities.date.range_filter, { - declared: true, - operators: ["gte", "gt", "lte", "lt"], - reason: "field_not_granted", - usable: false, - }); - assert.deepEqual(gmailMetadata.body.field_capabilities.from_email.lexical_search, { - declared: true, - reason: "field_not_granted", - usable: false, - }); - assert.deepEqual(gmailMetadata.body.field_capabilities.snippet.semantic_search, { - declared: true, - reason: "field_not_granted", - usable: false, - }); - assert.deepEqual( - gmailMetadata.body.expand_capabilities.map((entry: JsonObject) => ({ - name: entry.name, - reason: entry.reason, - usable: entry.usable, - })), - [ - { - name: "message_bodies", - reason: "related_stream_not_granted", - usable: false, - }, - { - name: "attachments", - reason: "related_stream_not_granted", - usable: false, - }, - ] - ); + assert.equal(gmailMetadata.body.field_capabilities.date, undefined); + assert.equal(gmailMetadata.body.field_capabilities.from_email, undefined); + assert.equal(gmailMetadata.body.field_capabilities.snippet, undefined); + assert.deepEqual(gmailMetadata.body.expand_capabilities, []); }); }); @@ -1061,7 +1054,7 @@ test("stream aggregate enforces grants and declared aggregate fields", async () }); }); -test("stream aggregate honors grant resources, time ranges, and request filters together", async () => { +test("stream aggregate enforces grant resources and time while rejecting client filters", async () => { await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { const ownerToken = await issueOwnerToken(asUrl, "aggregation_scope_owner"); const connectorId = spotifyManifest.connector_id; @@ -1096,7 +1089,13 @@ test("stream aggregate honors grant resources, time ranges, and request filters const url = `${rsUrl}/v1/streams/top_artists/aggregate` + "?metric=sum&field=popularity&filter[source_updated_at][lte]=2026-02-15T00:00:00Z"; - const { status, body } = await fetchJson(url, { + const rejected = await fetchJson(url, { + headers: { Authorization: `Bearer ${approved.token}` }, + }); + assert.equal(rejected.status, 400); + assert.equal(rejected.body.error.code, "invalid_request"); + + const { status, body } = await fetchJson(`${rsUrl}/v1/streams/top_artists/aggregate?metric=sum&field=popularity`, { headers: { Authorization: `Bearer ${approved.token}` }, }); assert.equal(status, 200); @@ -1417,6 +1416,32 @@ test("fields projection on a manifest-unknown field is rejected under a restrict }); }); +test("fields projection on a declared but ungranted field is rejected under a restricted grant", async () => { + await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { + const connectorId = spotifyManifest.connector_id; + const ownerToken = await issueOwnerToken(asUrl, "ungranted_fields_owner"); + await seedSpotifyTopArtists(rsUrl, ownerToken, connectorId, [ + { id: "a1", name: "A", popularity: 95, source_updated_at: "2026-01-01T00:00:00Z" }, + ]); + const approved = await approveGrant(asUrl, "ungranted_fields_owner", { + access_mode: "continuous", + client_id: "longview", + purpose_code: "https://pdpp.dev/purpose/analytics", + purpose_description: "projection conformance under a narrowed field grant", + source: { id: connectorId, kind: "connector" }, + streams: [{ fields: ["id", "name", "source_updated_at"], name: "top_artists" }], + }); + assert.ok(approved.token, `expected grant token, got ${JSON.stringify(approved)}`); + + const { status, body } = await fetchJson(`${rsUrl}/v1/streams/top_artists/records?fields=id,popularity`, { + headers: { Authorization: `Bearer ${approved.token}` }, + }); + assert.equal(status, 403, JSON.stringify(body)); + assert.equal(body.error.code, "field_not_granted"); + assert.equal(body.error.type, "permission_error"); + }); +}); + test("query-time view applies a real projection (not a silent no-op)", async () => { await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { // top_artists declares view `basic` -> fields [id, name, genres]. Reading @@ -1751,7 +1776,7 @@ test("exact filter on declared scalar field works", async () => { }); }); -test("expand hydrates declared has_many relations and respects child grant projection", async () => { +test("owner expansion hydrates declared has_many relations", async () => { await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { const ownerToken = await issueOwnerToken(asUrl, "expand_owner"); const connectorId = spotifyManifest.connector_id; @@ -1779,23 +1804,11 @@ test("expand hydrates declared has_many relations and respects child grant proje }, ]); - const approved = await approveGrant(asUrl, "expand_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read saved tracks with recent listening context", - streams: [ - { fields: ["id", "name", "saved_at"], name: "saved_tracks" }, - { fields: ["id", "track_id", "played_at"], name: "recently_played" }, - ], - }); - const { status, body } = await fetchJson( - `${rsUrl}/v1/streams/saved_tracks/records?expand=recently_played&expand_limit[recently_played]=1`, - { headers: { Authorization: `Bearer ${approved.token}` } } + `${rsUrl}/v1/streams/saved_tracks/records?connector_id=${encodeURIComponent(connectorId)}&expand=recently_played&expand_limit[recently_played]=1`, + { headers: { Authorization: `Bearer ${ownerToken}` } } ); - assert.equal(status, 200); + assert.equal(status, 200, JSON.stringify(body)); const record = body.data?.[0]; assert.ok(record, "expected one saved track"); assert.ok(record.expanded?.recently_played, "expanded relation should be present"); @@ -1804,13 +1817,12 @@ test("expand hydrates declared has_many relations and respects child grant proje assert.equal(record.expanded.recently_played.data.length, 1); // biome-ignore lint/style/useDestructuring: the property access names the fixture value at its point of use. const child = record.expanded.recently_played.data[0]; - assert.deepEqual(Object.keys(child.data || {}).sort(), ["id", "played_at", "track_id"]); - assert.ok(!("track_name" in (child.data || {}))); + assert.deepEqual(Object.keys(child.data || {}).sort(), ["id", "played_at", "track_id", "track_name"]); assert.equal(child.id, "play_1"); }); }); -test("single-record fetch honors declared expand and expand_limit", async () => { +test("owner single-record fetch honors declared expand and expand_limit", async () => { await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { const ownerToken = await issueOwnerToken(asUrl, "record_expand_owner"); const connectorId = spotifyManifest.connector_id; @@ -1838,24 +1850,16 @@ test("single-record fetch honors declared expand and expand_limit", async () => }, ]); - const approved = await approveGrant(asUrl, "record_expand_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read one saved track with recent listening context", - streams: [ - { fields: ["id", "name", "saved_at"], name: "saved_tracks" }, - { fields: ["id", "track_id", "played_at"], name: "recently_played" }, - ], - }); - const { status, body } = await fetchJson( - `${rsUrl}/v1/streams/saved_tracks/records/track_1?expand=recently_played&expand_limit[recently_played]=1`, - { headers: { Authorization: `Bearer ${approved.token}` } } + `${rsUrl}/v1/streams/saved_tracks/records/track_1?connector_id=${encodeURIComponent(connectorId)}&expand=recently_played&expand_limit[recently_played]=1`, + { headers: { Authorization: `Bearer ${ownerToken}` } } + ); + assert.equal(status, 200, JSON.stringify(body)); + assert.equal( + body.connector_key, + canonicalConnectorKey(spotifyManifest.connector_id), + "owner record detail carries current connector identity" ); - assert.equal(status, 200); - assert.equal(body.connector_key, "spotify", "record detail carries canonical source connector identity"); assert.ok(body.expanded?.recently_played, "expanded relation should be present on record detail"); assert.equal(body.expanded.recently_played.object, "list"); assert.equal(body.expanded.recently_played.has_more, true); @@ -1864,7 +1868,7 @@ test("single-record fetch honors declared expand and expand_limit", async () => }); }); -test("expand fails with insufficient_scope when the related stream is outside the grant", async () => { +test("client expansion rejects before related-stream grant evaluation", async () => { await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { const ownerToken = await issueOwnerToken(asUrl, "expand_scope_owner"); const connectorId = spotifyManifest.connector_id; @@ -1884,6 +1888,12 @@ test("expand fails with insufficient_scope when the related stream is outside th track_name: "Track 1", }, ]); + await materializeConnection({ + connectorId, + connectorInstanceId: "cin_query_contract_spotify_expand_scope", + displayName: "Spotify Expand Scope", + ownerSubjectId: "expand_scope_owner", + }); const approved = await approveGrant(asUrl, "expand_scope_owner", { access_mode: "continuous", @@ -1891,18 +1901,25 @@ test("expand fails with insufficient_scope when the related stream is outside th connector_id: connectorId, purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Read saved tracks only", - streams: [{ fields: ["id", "name", "saved_at"], name: "saved_tracks" }], + streams: [ + { + fields: ["id", "name", "saved_at"], + instance_ids: ["cin_query_contract_spotify_expand_scope"], + name: "saved_tracks", + }, + ], }); const { status, body } = await fetchJson(`${rsUrl}/v1/streams/saved_tracks/records?expand=recently_played`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(status, 403); - assert.equal(body.error.code, "insufficient_scope"); + assert.equal(status, 400); + assert.equal(body.error.code, "invalid_request"); + assert.equal(body.error.param, "expand"); }); }); -test("gmail messages expand message_bodies on list and detail reads with child projection", async () => { +test("owner Gmail message reads expand message_bodies on list and detail", async () => { await withHarness(async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl, "gmail_expand_body_owner"); const gmailManifest = readGmailManifest(); @@ -1920,43 +1937,35 @@ test("gmail messages expand message_bodies on list and detail reads with child p "message_bodies", ]); - const approved = await approveGrant(asUrl, "gmail_expand_body_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read Gmail messages with body context", - streams: [ - { fields: ["id", "thread_id", "subject", "received_at"], name: "messages" }, - { fields: ["id", "message_id", "body_text"], name: "message_bodies" }, - ], - }); - const list = await fetchJson( `${rsUrl}/v1/streams/messages/records?connector_id=${encodeURIComponent(connectorId)}&order=asc&expand=message_bodies`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); - assert.equal(list.status, 200); + assert.equal(list.status, 200, JSON.stringify(list.body)); assert.equal(list.body.data.length, 2); const messageWithBody = list.body.data.find((record: JsonObject) => record.id === "msg-1"); assert.ok(messageWithBody?.expanded?.message_bodies, "msg-1 should include body expansion"); assert.equal(messageWithBody.expanded.message_bodies.stream, "message_bodies"); assert.deepEqual(Object.keys(messageWithBody.expanded.message_bodies.data || {}).sort(), [ + "body_html", + "body_html_bytes", "body_source", "body_text", + "body_text_bytes", + "charset", + "content_languages", "id", "message_id", ]); assert.equal(messageWithBody.expanded.message_bodies.data.body_text, "Here is your train receipt for Milan."); - assert.ok(!("body_html" in messageWithBody.expanded.message_bodies.data)); const messageWithoutBody = list.body.data.find((record: JsonObject) => record.id === "msg-2"); assert.equal(messageWithoutBody?.expanded?.message_bodies, null); const detail = await fetchJson( `${rsUrl}/v1/streams/messages/records/msg-1?connector_id=${encodeURIComponent(connectorId)}&expand=message_bodies`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); assert.equal(detail.status, 200); assert.equal(detail.body.expanded.message_bodies.id, "body-msg-1"); @@ -1964,7 +1973,7 @@ test("gmail messages expand message_bodies on list and detail reads with child p }); }); -test("gmail messages expand attachment metadata with limits and missing-child parity", async () => { +test("owner Gmail message reads expand attachment metadata with limits and missing-child parity", async () => { await withHarness(async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl, "gmail_expand_attachment_owner"); const gmailManifest = readGmailManifest(); @@ -1973,26 +1982,11 @@ test("gmail messages expand attachment metadata with limits and missing-child pa assert.equal(reg.status, 201, "register gmail manifest"); await seedGmailExpansionFixture(rsUrl, ownerToken, connectorId); - const approved = await approveGrant(asUrl, "gmail_expand_attachment_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read Gmail messages with attachment metadata", - streams: [ - { fields: ["id", "thread_id", "subject", "received_at", "has_attachments"], name: "messages" }, - { - fields: ["id", "message_id", "filename", "content_type", "part_index", "message_received_at"], - name: "attachments", - }, - ], - }); - const { status, body } = await fetchJson( `${rsUrl}/v1/streams/messages/records?connector_id=${encodeURIComponent(connectorId)}&order=asc&expand=attachments&expand_limit[attachments]=2`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); - assert.equal(status, 200); + assert.equal(status, 200, JSON.stringify(body)); const messageWithAttachments = body.data.find((record: JsonObject) => record.id === "msg-1"); assert.ok(messageWithAttachments?.expanded?.attachments, "msg-1 should include attachment expansion"); @@ -2003,19 +1997,21 @@ test("gmail messages expand attachment metadata with limits and missing-child pa ["att-1", "att-2"] ); assert.deepEqual(Object.keys(messageWithAttachments.expanded.attachments.data[0].data || {}).sort(), [ + "blob_ref", + "content_id", + "content_sha256", "content_type", + "encoding", "filename", + "hydration_error", "hydration_status", "id", + "is_inline", "message_id", "message_received_at", "part_index", + "size_bytes", ]); - assert.equal( - JSON.stringify(messageWithAttachments.expanded.attachments).includes("blob_ref"), - false, - "attachment expansion must not expose blob_ref unless the child grant includes it" - ); const messageWithoutAttachments = body.data.find((record: JsonObject) => record.id === "msg-2"); assert.equal(messageWithoutAttachments.expanded.attachments.object, "list"); @@ -2024,7 +2020,7 @@ test("gmail messages expand attachment metadata with limits and missing-child pa }); }); -test("gmail messages expand hydrated attachments with grant-visible blob_ref fetch_url", async () => { +test("owner Gmail message reads expand hydrated attachments with blob fetch_url", async () => { await withHarness(async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl, "gmail_expand_attachment_blob_owner"); const gmailManifest = readGmailManifest(); @@ -2114,9 +2110,9 @@ test("gmail messages expand hydrated attachments with grant-visible blob_ref fet const expanded = await fetchJson( `${rsUrl}/v1/streams/messages/records?connector_id=${encodeURIComponent(connectorId)}&expand=attachments`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); - assert.equal(expanded.status, 200); + assert.equal(expanded.status, 200, JSON.stringify(expanded.body)); const message = expanded.body.data.find((record: JsonObject) => record.id === "msg-blob"); const attachment = message?.expanded?.attachments?.data?.[0]; assert.ok(attachment, "expanded attachment should be present"); @@ -2154,12 +2150,13 @@ test("gmail message expansion rejects missing child grant and reverse thread rel `${rsUrl}/v1/streams/messages/records?connector_id=${encodeURIComponent(connectorId)}&expand=message_bodies`, { headers: { Authorization: `Bearer ${approved.token}` } } ); - assert.equal(missingChildGrant.status, 403); - assert.equal(missingChildGrant.body.error.code, "insufficient_scope"); + assert.equal(missingChildGrant.status, 400); + assert.equal(missingChildGrant.body.error.code, "invalid_request"); + assert.equal(missingChildGrant.body.error.param, "expand"); const reverseThread = await fetchJson( `${rsUrl}/v1/streams/messages/records?connector_id=${encodeURIComponent(connectorId)}&expand=thread`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); assert.equal(reverseThread.status, 400); assert.equal(reverseThread.body.error.code, "invalid_expand"); @@ -2513,7 +2510,7 @@ test("repositories → issues shape fails the first-party manifest requiredness ); }); -test("github user expands user_stats filtered by user_id under a both-granted token", async () => { +test("owner GitHub user reads expand user_stats by current user_id relation", async () => { await withHarness(async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl, "github_expand_owner"); const githubManifest = readGithubManifest(); @@ -2522,23 +2519,11 @@ test("github user expands user_stats filtered by user_id under a both-granted to assert.equal(reg.status, 201, "register github manifest"); await seedGithubExpansionFixture(rsUrl, ownerToken, connectorId); - const approved = await approveGrant(asUrl, "github_expand_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read GitHub profile with daily stats", - streams: [ - { fields: ["id", "login", "name", "updated_at"], name: "user" }, - { fields: ["id", "user_id", "observed_on", "followers"], name: "user_stats" }, - ], - }); - const list = await fetchJson( `${rsUrl}/v1/streams/user/records?connector_id=${encodeURIComponent(connectorId)}&order=asc&expand=user_stats`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); - assert.equal(list.status, 200); + assert.equal(list.status, 200, JSON.stringify(list.body)); const octocat = list.body.data.find((record: JsonObject) => record.id === "101"); assert.ok(octocat?.expanded?.user_stats, "octocat carries hydrated user_stats"); @@ -2558,7 +2543,7 @@ test("github user expands user_stats filtered by user_id under a both-granted to }); }); -test("github user_stats expand_limit caps the child fan-out and reports has_more", async () => { +test("owner GitHub user_stats expand_limit caps the child fan-out and reports has_more", async () => { await withHarness(async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl, "github_expand_limit_owner"); const githubManifest = readGithubManifest(); @@ -2567,23 +2552,11 @@ test("github user_stats expand_limit caps the child fan-out and reports has_more assert.equal(reg.status, 201, "register github manifest"); await seedGithubExpansionFixture(rsUrl, ownerToken, connectorId); - const approved = await approveGrant(asUrl, "github_expand_limit_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read GitHub profile with capped daily stats", - streams: [ - { fields: ["id", "login"], name: "user" }, - { fields: ["id", "user_id", "observed_on"], name: "user_stats" }, - ], - }); - const detail = await fetchJson( `${rsUrl}/v1/streams/user/records/101?connector_id=${encodeURIComponent(connectorId)}&expand=user_stats&expand_limit[user_stats]=2`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); - assert.equal(detail.status, 200); + assert.equal(detail.status, 200, JSON.stringify(detail.body)); assert.equal(detail.body.expanded.user_stats.data.length, 2); assert.equal(detail.body.expanded.user_stats.has_more, true, "octocat has 3 stats rows, capped at 2"); }); @@ -2598,7 +2571,7 @@ test("github user expansion rejects requests missing the user_stats grant", asyn assert.equal(reg.status, 201, "register github manifest"); await seedGithubExpansionFixture(rsUrl, ownerToken, connectorId); - // user-only grant: expanding the ungranted child fails with insufficient_scope. + // Client expansion closes before evaluating the related-stream grant. const userOnly = await approveGrant(asUrl, "github_expand_reject_owner", { access_mode: "continuous", client_id: "longview", @@ -2612,26 +2585,14 @@ test("github user expansion rejects requests missing the user_stats grant", asyn `${rsUrl}/v1/streams/user/records?connector_id=${encodeURIComponent(connectorId)}&expand=user_stats`, { headers: { Authorization: `Bearer ${userOnly.token}` } } ); - assert.equal(missingStats.status, 403); - assert.equal(missingStats.body.error.code, "insufficient_scope"); + assert.equal(missingStats.status, 400); + assert.equal(missingStats.body.error.code, "invalid_request"); + assert.equal(missingStats.body.error.param, "expand"); - // Reverse expansion is not declared on the manifest. Grant user_stats so the - // request reaches expand-validation (not the grant gate) and the rejection is - // genuinely about the undeclared reverse relation, not a missing scope. - const bothGranted = await approveGrant(asUrl, "github_expand_reject_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read GitHub profile + stats", - streams: [ - { fields: ["id", "login"], name: "user" }, - { fields: ["id", "user_id", "observed_on"], name: "user_stats" }, - ], - }); + // Owner expansion reaches current declaration validation without a grant gate. const reverse = await fetchJson( `${rsUrl}/v1/streams/user_stats/records?connector_id=${encodeURIComponent(connectorId)}&expand=user`, - { headers: { Authorization: `Bearer ${bothGranted.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); assert.equal(reverse.status, 400); assert.equal(reverse.body.error.code, "invalid_expand"); @@ -2649,18 +2610,9 @@ test("github repositories → issues expansion is not declared in this change", { full_name: "octocat/hello-world", id: "r1", updated_at: "2026-04-01T10:00:00Z" }, ]); - const approved = await approveGrant(asUrl, "github_repo_issues_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read GitHub repositories only", - streams: [{ fields: ["id", "full_name"], name: "repositories" }], - }); - const resp = await fetchJson( `${rsUrl}/v1/streams/repositories/records?connector_id=${encodeURIComponent(connectorId)}&expand=issues`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); assert.equal(resp.status, 400); assert.equal(resp.body.error.code, "invalid_expand"); @@ -2676,20 +2628,8 @@ test("github user stream metadata surfaces the user_stats expand capability with assert.equal(reg.status, 201, "register github manifest"); await seedGithubExpansionFixture(rsUrl, ownerToken, connectorId); - // Both streams granted → usable: true with full target naming. - const both = await approveGrant(asUrl, "github_metadata_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read GitHub profile + stats", - streams: [ - { fields: ["id", "login"], name: "user" }, - { fields: ["id", "user_id", "observed_on"], name: "user_stats" }, - ], - }); const bothMeta = await fetchJson(`${rsUrl}/v1/streams/user?connector_id=${encodeURIComponent(connectorId)}`, { - headers: { Authorization: `Bearer ${both.token}` }, + headers: { Authorization: `Bearer ${ownerToken}` }, }); assert.equal(bothMeta.status, 200); const usableEntry = bothMeta.body.expand_capabilities.find((entry: JsonObject) => entry.name === "user_stats"); @@ -2700,31 +2640,10 @@ test("github user stream metadata surfaces the user_stats expand capability with assert.equal(usableEntry.cardinality, "has_many"); assert.equal(usableEntry.usable, true); assert.equal(usableEntry.granted, true); - - // user-only grant → entry still present, inert, with the not-granted reason. - const userOnly = await approveGrant(asUrl, "github_metadata_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read GitHub profile only", - streams: [{ fields: ["id", "login"], name: "user" }], - }); - const userOnlyMeta = await fetchJson(`${rsUrl}/v1/streams/user?connector_id=${encodeURIComponent(connectorId)}`, { - headers: { Authorization: `Bearer ${userOnly.token}` }, - }); - assert.equal(userOnlyMeta.status, 200); - const inertEntry = userOnlyMeta.body.expand_capabilities.find((entry: JsonObject) => entry.name === "user_stats"); - assert.ok(inertEntry, "declared relation stays visible even when not readable"); - assert.equal(inertEntry.target_stream, "user_stats"); - assert.equal(inertEntry.child_parent_key_field, "user_id"); - assert.equal(inertEntry.usable, false); - assert.equal(inertEntry.granted, false); - assert.equal(inertEntry.reason, "related_stream_not_granted"); }); }); -test("slack messages expand message_attachments and reactions on list and detail reads", async () => { +test("owner Slack message reads expand attachments and reactions on list and detail", async () => { await withHarness(async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl, "slack_expand_owner"); const slackManifest = readSlackManifest(); @@ -2742,24 +2661,11 @@ test("slack messages expand message_attachments and reactions on list and detail "reactions", ]); - const approved = await approveGrant(asUrl, "slack_expand_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read Slack messages with link previews and reactions", - streams: [ - { fields: ["id", "channel_id", "sent_at", "text"], name: "messages" }, - { fields: ["id", "message_id", "service_name", "title"], name: "message_attachments" }, - { fields: ["id", "message_id", "emoji", "user_id"], name: "reactions" }, - ], - }); - const list = await fetchJson( `${rsUrl}/v1/streams/messages/records?connector_id=${encodeURIComponent(connectorId)}&order=asc&expand=message_attachments&expand=reactions`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); - assert.equal(list.status, 200); + assert.equal(list.status, 200, JSON.stringify(list.body)); assert.equal(list.body.data.length, 2); const messageWithChildren = list.body.data.find((record: JsonObject) => record.id === "C1:1700000001.000100"); @@ -2772,11 +2678,15 @@ test("slack messages expand message_attachments and reactions on list and detail ); assert.deepEqual(Object.keys(messageWithChildren.expanded.message_attachments.data[0].data || {}).sort(), [ "channel_id", + "fallback", + "from_url", "id", "index", "message_id", "service_name", + "text", "title", + "title_link", ]); assert.ok(messageWithChildren.expanded.reactions); @@ -2796,14 +2706,14 @@ test("slack messages expand message_attachments and reactions on list and detail const detail = await fetchJson( `${rsUrl}/v1/streams/messages/records/${encodeURIComponent("C1:1700000001.000100")}?connector_id=${encodeURIComponent(connectorId)}&expand=message_attachments`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); assert.equal(detail.status, 200); assert.equal(detail.body.expanded.message_attachments.data.length, 3); }); }); -test("slack messages expand_limit caps message_attachments and reactions independently", async () => { +test("owner Slack expand_limit caps attachments and reactions independently", async () => { await withHarness(async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl, "slack_expand_limit_owner"); const slackManifest = readSlackManifest(); @@ -2812,24 +2722,11 @@ test("slack messages expand_limit caps message_attachments and reactions indepen assert.equal(reg.status, 201, "register slack manifest"); await seedSlackExpansionFixture(rsUrl, ownerToken, connectorId); - const approved = await approveGrant(asUrl, "slack_expand_limit_owner", { - access_mode: "continuous", - client_id: "longview", - connector_id: connectorId, - purpose_code: "https://pdpp.dev/purpose/personalization", - purpose_description: "Read Slack messages with capped child fan-out", - streams: [ - { fields: ["id", "channel_id", "sent_at"], name: "messages" }, - { fields: ["id", "message_id", "title"], name: "message_attachments" }, - { fields: ["id", "message_id", "emoji"], name: "reactions" }, - ], - }); - const { status, body } = await fetchJson( `${rsUrl}/v1/streams/messages/records?connector_id=${encodeURIComponent(connectorId)}&order=asc&expand=message_attachments&expand=reactions&expand_limit[message_attachments]=2&expand_limit[reactions]=1`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); - assert.equal(status, 200); + assert.equal(status, 200, JSON.stringify(body)); const message = body.data.find((record: JsonObject) => record.id === "C1:1700000001.000100"); assert.equal(message.expanded.message_attachments.has_more, true); assert.equal(message.expanded.message_attachments.data.length, 2); @@ -2838,7 +2735,7 @@ test("slack messages expand_limit caps message_attachments and reactions indepen const overMax = await fetchJson( `${rsUrl}/v1/streams/messages/records?connector_id=${encodeURIComponent(connectorId)}&expand=reactions&expand_limit[reactions]=999`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); assert.equal(overMax.status, 400); assert.equal(overMax.body.error.code, "invalid_expand"); @@ -2867,12 +2764,13 @@ test("slack message expansion rejects requests missing the child grant", async ( `${rsUrl}/v1/streams/messages/records?connector_id=${encodeURIComponent(connectorId)}&expand=message_attachments`, { headers: { Authorization: `Bearer ${approved.token}` } } ); - assert.equal(missingAttachments.status, 403); - assert.equal(missingAttachments.body.error.code, "insufficient_scope"); + assert.equal(missingAttachments.status, 400); + assert.equal(missingAttachments.body.error.code, "invalid_request"); + assert.equal(missingAttachments.body.error.param, "expand"); const reverseChannel = await fetchJson( `${rsUrl}/v1/streams/messages/records?connector_id=${encodeURIComponent(connectorId)}&expand=channel`, - { headers: { Authorization: `Bearer ${approved.token}` } } + { headers: { Authorization: `Bearer ${ownerToken}` } } ); assert.equal(reverseChannel.status, 400); assert.equal(reverseChannel.body.error.code, "invalid_expand"); @@ -2955,14 +2853,23 @@ test("blob upload requires owner authority and validates binding inputs", async await withHarness(async ({ asUrl, rsUrl, spotifyManifest }) => { const ownerToken = await issueOwnerToken(asUrl, "blob_upload_validation_owner"); const connectorId = spotifyManifest.connector_id; + await materializeConnection({ + connectorId, + connectorInstanceId: "cin_query_contract_spotify_blob", + displayName: "Spotify Blob", + ownerSubjectId: "blob_upload_validation_owner", + }); const grant = await approveGrant(asUrl, "blob_upload_validation_owner", { access_mode: "continuous", client_id: "longview", connector_id: connectorId, purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Read saved tracks only", - streams: [{ fields: ["id", "name", "saved_at"], name: "saved_tracks" }], + streams: [ + { fields: ["id", "name", "saved_at"], instance_ids: ["cin_query_contract_spotify_blob"], name: "saved_tracks" }, + ], }); + assert.ok(grant.token, `expected issued grant token, got ${JSON.stringify(grant)}`); const clientUpload = await uploadBlob( rsUrl, diff --git a/reference-implementation/test/reconcile-active-summary-evidence-oracle.test.ts b/reference-implementation/test/reconcile-active-summary-evidence-oracle.test.ts index a531ce540..98795e244 100644 --- a/reference-implementation/test/reconcile-active-summary-evidence-oracle.test.ts +++ b/reference-implementation/test/reconcile-active-summary-evidence-oracle.test.ts @@ -68,12 +68,16 @@ const MANIFEST: any = { name: STREAM, primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, { coverage_strategy: "full_inventory", name: EMPTY_STREAM, primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", diff --git a/reference-implementation/test/record-expand-helpers-branches.test.ts b/reference-implementation/test/record-expand-helpers-branches.test.ts index 29dcf847c..9bf1a7d57 100644 --- a/reference-implementation/test/record-expand-helpers-branches.test.ts +++ b/reference-implementation/test/record-expand-helpers-branches.test.ts @@ -13,7 +13,7 @@ * expand_limit shape/positivity/max/cardinality guards, dangling * expand_limit relations, duplicate-name dedup, and the insufficient_scope * child-grant gate — unpinned. `buildEffectiveFilter`, `normalizePrimaryKey`, - * `parseIntegerValue`, and `assertSafeJsonField` have no direct coverage at + * `parseIntegerValue`, and `assertNonEmptyJsonField` have no direct coverage at * all. * * A mutant that flips a `<=`/`<` boundary, drops a shape guard, mis-labels an @@ -28,22 +28,19 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - assertSafeJsonField, + assertNonEmptyJsonField, buildEffectiveFilter, invalidQueryError, normalizeExpandRequest, normalizePrimaryKey, parseIntegerValue, - SAFE_JSON_FIELD, } from "../server/record-expand-helpers.ts"; interface QueryError extends Error { code?: string; } -const UNSAFE_FILTER_FIELD_PATTERN = /Unsafe JSON field filter/; -const UNSAFE_JSON_FIELD_PATTERN = /Unsafe JSON field/; -const UNSAFE_SORT_FIELD_PATTERN = /Unsafe JSON field sort/; +const INVALID_JSON_FIELD_PATTERN = /non-empty string/; function isQueryError(value: unknown): value is QueryError { return value instanceof Error; @@ -124,49 +121,35 @@ test("parseIntegerValue rejects non-string, non-number inputs", () => { assert.equal(parseIntegerValue({}), null); }); -// ─── assertSafeJsonField / SAFE_JSON_FIELD ─────────────────────────────── +// ─── assertNonEmptyJsonField ────────────────────────────────────────────── -test("SAFE_JSON_FIELD matches an identifier and rejects structural chars", () => { - assert.ok(SAFE_JSON_FIELD.test("good_field_1")); - assert.ok(!SAFE_JSON_FIELD.test("1bad")); - assert.ok(!SAFE_JSON_FIELD.test("a.b")); - assert.ok(!SAFE_JSON_FIELD.test("a b")); -}); - -test("assertSafeJsonField accepts a valid identifier", () => { - assert.doesNotThrow(() => assertSafeJsonField("subject_id", "sort")); -}); - -test("assertSafeJsonField rejects an identifier starting with a digit", () => { - assert.throws( - () => assertSafeJsonField("1bad", "sort"), - (err: unknown) => isQueryError(err) && UNSAFE_SORT_FIELD_PATTERN.test(err.message) - ); -}); - -test("assertSafeJsonField rejects a dotted / injection path", () => { - assert.throws( - () => assertSafeJsonField("a.b", "filter"), - (err: unknown) => isQueryError(err) && UNSAFE_FILTER_FIELD_PATTERN.test(err.message) - ); -}); - -test("assertSafeJsonField rejects a non-string field", () => { +test("assertNonEmptyJsonField accepts literal field names and rejects non-strings", () => { + for (const field of ["subject_id", "1bad", "a.b", "has-dash", 'said "when"', "時刻"]) { + assert.doesNotThrow(() => assertNonEmptyJsonField(field, "sort")); + } assert.throws( - () => assertSafeJsonField(5, "sort"), - (err: unknown) => isQueryError(err) && UNSAFE_JSON_FIELD_PATTERN.test(err.message) + () => assertNonEmptyJsonField(5, "sort"), + (err: unknown) => isQueryError(err) && INVALID_JSON_FIELD_PATTERN.test(err.message) ); }); // ─── buildEffectiveFilter ──────────────────────────────────────────────── test("buildEffectiveFilter carries grant scopes through when no request fields", () => { - const eff = buildEffectiveFilter({ fields: ["a", "b"], resources: ["r1"], time_range: { since: "x" } }, {}, []); + const eff = buildEffectiveFilter( + { + fields: ["a", "b"], + resources: ["r1"], + time_constraint: { field: "frozen_at", since: "2026-01-01T00:00:00Z" }, + }, + {}, + [] + ); assert.deepEqual(eff, { - consentTimeField: null, fields: ["a", "b"], resources: ["r1"], - timeRange: { since: "x" }, + timeConstraint: { field: "frozen_at", since: "2026-01-01T00:00:00Z" }, + timeConstraintField: "frozen_at", }); }); diff --git a/reference-implementation/test/record-expand-helpers-oracle.test.ts b/reference-implementation/test/record-expand-helpers-oracle.test.ts index 5cd231728..cbe6ae0d3 100644 --- a/reference-implementation/test/record-expand-helpers-oracle.test.ts +++ b/reference-implementation/test/record-expand-helpers-oracle.test.ts @@ -6,18 +6,15 @@ // (filters non-string/empty array members, wraps a scalar, [] otherwise); // - parseIntegerValue: the integer coercion behind filter comparisons (accepts // integer numbers, trims decimal strings, rejects floats / non-numeric); -// - assertSafeJsonField: the injection guard restricting an interpolated -// `$.<field>` JSON path to a safe SQL identifier. +// - assertNonEmptyJsonField: validates the Source contract's non-empty +// literal top-level field reference before backends quote or bind it. // All pure, all previously untested by name. No DB. import assert from "node:assert/strict"; import test from "node:test"; -import { - assertSafeJsonField, - normalizePrimaryKey, - parseIntegerValue, - SAFE_JSON_FIELD, -} from "../server/record-expand-helpers.ts"; +import { assertNonEmptyJsonField, normalizePrimaryKey, parseIntegerValue } from "../server/record-expand-helpers.ts"; + +const NON_EMPTY_STRING_ERROR = /non-empty string/; test("normalizePrimaryKey cleans an array, wraps a scalar string, and returns [] otherwise", () => { assert.deepEqual(normalizePrimaryKey(["a", "", "b", 123, null]), ["a", "b"]); @@ -41,21 +38,10 @@ test("parseIntegerValue accepts integer numbers and integer strings, rejecting f assert.equal(parseIntegerValue(undefined), null); }); -test("SAFE_JSON_FIELD admits identifier-shaped fields and rejects the rest", () => { - assert.ok(SAFE_JSON_FIELD.test("field_1")); - assert.ok(SAFE_JSON_FIELD.test("_x")); - assert.ok(!SAFE_JSON_FIELD.test("1field")); // leading digit - assert.ok(!SAFE_JSON_FIELD.test("a.b")); // dot - assert.ok(!SAFE_JSON_FIELD.test("a b")); // space - assert.ok(!SAFE_JSON_FIELD.test("")); // empty -}); - -test("assertSafeJsonField throws on an unsafe field and passes a safe identifier", () => { - // biome-ignore lint/performance/useTopLevelRegex: test assertion patterns remain colocated with the assertion they explain. - assert.throws(() => assertSafeJsonField("a.b", "field"), /Unsafe JSON field field/); - // biome-ignore lint/performance/useTopLevelRegex: test assertion patterns remain colocated with the assertion they explain. - assert.throws(() => assertSafeJsonField(123, "field"), /Unsafe JSON field field/); - // biome-ignore lint/performance/useTopLevelRegex: test assertion patterns remain colocated with the assertion they explain. - assert.throws(() => assertSafeJsonField("", "field"), /Unsafe JSON field field/); - assert.doesNotThrow(() => assertSafeJsonField("good_field", "field")); +test("assertNonEmptyJsonField accepts arbitrary literal keys and rejects absent values", () => { + for (const field of ["field_1", "1field", "a.b", "a b", "a-b", 'a"b', "時刻"]) { + assert.doesNotThrow(() => assertNonEmptyJsonField(field, "field")); + } + assert.throws(() => assertNonEmptyJsonField(123, "field"), NON_EMPTY_STRING_ERROR); + assert.throws(() => assertNonEmptyJsonField("", "field"), NON_EMPTY_STRING_ERROR); }); diff --git a/reference-implementation/test/record-expand-helpers-pure.test.ts b/reference-implementation/test/record-expand-helpers-pure.test.ts index 953d163fa..87bbeca34 100644 --- a/reference-implementation/test/record-expand-helpers-pure.test.ts +++ b/reference-implementation/test/record-expand-helpers-pure.test.ts @@ -12,11 +12,8 @@ // the identity guard's field list. // - parseIntegerValue — strict integer coercion for numeric query // params (whitespace/sign/non-numeric rules). -// - SAFE_JSON_FIELD / -// assertSafeJsonField — the SQL-injection guard that lets a backend -// interpolate only `$.<field>` identifiers into -// SQL. This is a security boundary; a loosened -// regex must fail loudly here. +// - assertNonEmptyJsonField preserves arbitrary literal top-level JSON +// keys so a backend can quote or bind them. // - invalidQueryError — the typed query-error factory + default code. // // These do not touch grant/scope logic; assertions observe behavior only. @@ -25,17 +22,18 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - assertSafeJsonField, + assertNonEmptyJsonField, invalidQueryError, normalizePrimaryKey, parseIntegerValue, - SAFE_JSON_FIELD, } from "../server/record-expand-helpers.ts"; interface QueryError extends Error { code?: string; } +const NON_EMPTY_STRING_ERROR = /non-empty string/; + function isQueryError(value: unknown): value is QueryError { return value instanceof Error; } @@ -108,61 +106,17 @@ test("parseIntegerValue rejects non-string, non-number inputs", () => { } }); -// ─── SAFE_JSON_FIELD / assertSafeJsonField (SQL-injection guard) ───────────── - -test("SAFE_JSON_FIELD accepts plain identifiers only", () => { - for (const ok of ["id", "created_at", "_private", "A1", "field_9", "__x__"]) { - assert.ok(SAFE_JSON_FIELD.test(ok), `${ok} should be a safe field`); - } -}); - -test("SAFE_JSON_FIELD rejects anything that could break out of a $.<field> path", () => { - // Leading digit, dots, quotes, brackets, whitespace, SQL/path metacharacters, - // and empty string must all be rejected so they can never be interpolated. - for (const bad of [ - "", - "1field", - "a.b", - "a b", - "a-b", - 'a"b', - "a'b", - "a;b", - "a)b", - "a]b", - "a$b", - "a\nb", - "'; DROP TABLE records; --", - "field ", // trailing space - " field", // leading space - "weird.key", - ]) { - assert.equal(SAFE_JSON_FIELD.test(bad), false, `${JSON.stringify(bad)} must be rejected`); - } -}); - -test("assertSafeJsonField is a no-op for a safe field and throws for an unsafe one", () => { - assert.doesNotThrow(() => assertSafeJsonField("created_at", "cursor_field")); +// ─── assertNonEmptyJsonField ──────────────────────────────────────────────── - // biome-ignore lint/suspicious/noEvolvingTypes: the accumulator intentionally represents heterogeneous fixture observations. - // biome-ignore lint/suspicious/noImplicitAnyLet: the test initializes the value from runtime fixture state before its stable type is known. - let caught; - try { - assertSafeJsonField("a'; DROP TABLE records; --", "cursor_field"); - } catch (e) { - caught = e; +test("assertNonEmptyJsonField accepts arbitrary literal keys", () => { + for (const field of ["created_at", "1field", "a.b", "a-b", 'a"b', "a'b", "時刻"]) { + assert.doesNotThrow(() => assertNonEmptyJsonField(field, "cursor_field")); } - assert.ok(caught instanceof Error, "an unsafe field throws"); - // The label and the offending value are surfaced (JSON-stringified) so the - // failure is diagnosable without leaking a raw value into SQL. - // biome-ignore lint/performance/useTopLevelRegex: test assertion patterns remain colocated with the assertion they explain. - assert.match(caught.message, /Unsafe JSON field cursor_field/); - // biome-ignore lint/performance/useTopLevelRegex: test assertion patterns remain colocated with the assertion they explain. - assert.match(caught.message, /DROP TABLE/); }); -test("assertSafeJsonField throws for a non-string field", () => { +test("assertNonEmptyJsonField rejects empty and non-string values", () => { + assert.throws(() => assertNonEmptyJsonField("", "field"), NON_EMPTY_STRING_ERROR); for (const bad of [null, undefined, 42, {}, ["id"]]) { - assert.throws(() => assertSafeJsonField(bad, "field"), Error); + assert.throws(() => assertNonEmptyJsonField(bad, "field"), Error); } }); diff --git a/reference-implementation/test/record-expand-helpers-validation.test.ts b/reference-implementation/test/record-expand-helpers-validation.test.ts index 47a2be52d..19d0f7a0b 100644 --- a/reference-implementation/test/record-expand-helpers-validation.test.ts +++ b/reference-implementation/test/record-expand-helpers-validation.test.ts @@ -11,28 +11,25 @@ * * - normalizePrimaryKey (array/scalar/empty normalization) * - parseIntegerValue (integer coercion + strict digit regex) - * - assertSafeJsonField (the SQL-safety allowlist guard that THROWS on - * any field name outside /^[A-Za-z_][A-Za-z_0-9]*$/) + * - assertNonEmptyJsonField (validates non-empty literal top-level keys) * - buildEffectiveFilter (grant∩request field projection + required-field * union) * - normalizeExpandRequest (the whole invalid_expand / insufficient_scope * error tree + limit clamping) * - * The `assertSafeJsonField` cases are the security-relevant ones: a mutant - * that loosens the regex (e.g. allows a leading digit, a dot, or a quote) - * would let unsafe identifiers reach SQL interpolation, and turns red here. + * SQL builders quote or bind this value; this helper only rejects absent field + * names so valid JSON property names cannot be narrowed into identifiers. */ import assert from "node:assert/strict"; import test from "node:test"; import { - assertSafeJsonField, + assertNonEmptyJsonField, buildEffectiveFilter, normalizeExpandRequest, normalizePrimaryKey, parseIntegerValue, - SAFE_JSON_FIELD, } from "../server/record-expand-helpers.ts"; interface QueryError extends Error { @@ -90,36 +87,29 @@ test("parseIntegerValue: accepts int number / digit strings; rejects floats, bla assert.equal(parseIntegerValue(null), null); }); -test("assertSafeJsonField: passes valid identifiers, THROWS on anything outside the allowlist", () => { - // Valid: letter/underscore start, then letters/digits/underscores. - assert.equal(assertSafeJsonField("emitted_at", "x"), undefined); - assert.equal(assertSafeJsonField("_private", "x"), undefined); - assert.equal(assertSafeJsonField("Field9", "x"), undefined); - assert.ok(SAFE_JSON_FIELD.test("emitted_at")); - - // A leading digit is unsafe (kills a mutant that drops the anchor). - assertThrowsCode(() => assertSafeJsonField("9field", "sort"), undefined, "Unsafe JSON field sort"); - // A dot (nested path) is unsafe. - assertThrowsCode(() => assertSafeJsonField("a.b", "sort"), undefined, "Unsafe JSON field"); - // A quote / SQL-injection attempt is unsafe. - assertThrowsCode(() => assertSafeJsonField('a"; DROP', "sort"), undefined, "Unsafe JSON field"); - // Whitespace / empty / non-string are unsafe. - assertThrowsCode(() => assertSafeJsonField("a b", "sort")); - assertThrowsCode(() => assertSafeJsonField("", "sort")); - assertThrowsCode(() => assertSafeJsonField(123, "sort")); +test("assertNonEmptyJsonField: accepts literal JSON keys and rejects absent values", () => { + for (const field of ["emitted_at", "9field", "a.b", 'a"; DROP', "a b", "時刻"]) { + assert.equal(assertNonEmptyJsonField(field, "x"), undefined); + } + assertThrowsCode(() => assertNonEmptyJsonField("", "sort"), undefined, "non-empty string"); + assertThrowsCode(() => assertNonEmptyJsonField(123, "sort"), undefined, "non-empty string"); }); test("buildEffectiveFilter: intersects request fields with grant, unions required fields", () => { // Grant limits to [a,b,c]; request narrows to [b,c,z] -> intersection [b,c]. const eff = buildEffectiveFilter( - { fields: ["a", "b", "c"], resources: ["k1"], time_range: { since: "t" } }, + { + fields: ["a", "b", "c"], + resources: ["k1"], + time_constraint: { field: "frozen_at", since: "2026-01-01T00:00:00Z" }, + }, { fields: ["b", "c", "z"] }, [] ); assert.deepEqual(eff.fields, ["b", "c"]); - assert.deepEqual(eff.timeRange, { since: "t" }); + assert.deepEqual(eff.timeConstraint, { field: "frozen_at", since: "2026-01-01T00:00:00Z" }); + assert.equal(eff.timeConstraintField, "frozen_at"); assert.deepEqual(eff.resources, ["k1"]); - assert.equal(eff.consentTimeField, null); // No grant field limit + request fields -> request fields used verbatim. const eff2 = buildEffectiveFilter({}, { fields: ["x", "y"] }, []); diff --git a/reference-implementation/test/record-expand-helpers.test.ts b/reference-implementation/test/record-expand-helpers.test.ts index 30622a154..81a64b4af 100644 --- a/reference-implementation/test/record-expand-helpers.test.ts +++ b/reference-implementation/test/record-expand-helpers.test.ts @@ -16,8 +16,8 @@ import { strict as assert } from "node:assert/strict"; import { test } from "node:test"; import { + assertNonEmptyJsonField, assertRecordIdentity, - assertSafeJsonField, invalidQueryError, normalizePrimaryKey, parseIntegerValue, @@ -113,15 +113,13 @@ test("parseIntegerValue rejects non-integers and non-numeric strings", () => { assert.equal(parseIntegerValue("12x"), null); }); -test("assertSafeJsonField accepts identifier-shaped field names", () => { - assert.doesNotThrow(() => assertSafeJsonField("body", "field")); - assert.doesNotThrow(() => assertSafeJsonField("_private0", "field")); +test("assertNonEmptyJsonField accepts literal top-level JSON keys", () => { + for (const field of ["body", "has-dash", "has.dot", 'said "when"', "時刻"]) { + assert.doesNotThrow(() => assertNonEmptyJsonField(field, "field")); + } }); -test("assertSafeJsonField rejects unsafe field names", () => { - assert.throws(() => assertSafeJsonField("0leading", "field")); - assert.throws(() => assertSafeJsonField("has-dash", "field")); - assert.throws(() => assertSafeJsonField("has.dot", "field")); - assert.throws(() => assertSafeJsonField("", "field")); - assert.throws(() => assertSafeJsonField(null, "field")); +test("assertNonEmptyJsonField rejects absent field names", () => { + assert.throws(() => assertNonEmptyJsonField("", "field")); + assert.throws(() => assertNonEmptyJsonField(null, "field")); }); diff --git a/reference-implementation/test/record-expand-instance-authorization.test.ts b/reference-implementation/test/record-expand-instance-authorization.test.ts new file mode 100644 index 000000000..3e6a6fdf7 --- /dev/null +++ b/reference-implementation/test/record-expand-instance-authorization.test.ts @@ -0,0 +1,295 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { registerConnector } from "../server/auth.ts"; +import { closeDb, initDb } from "../server/db.ts"; +import { closePostgresStorage, initPostgresStorage, postgresQuery } from "../server/postgres-storage.ts"; +import { getRecord as getRecordUntyped, ingestRecord, queryRecords as queryRecordsUntyped } from "../server/records.ts"; + +interface StorageTarget { + connector_id: string; + connector_instance_id: string; +} +type Manifest = Parameters<typeof registerConnector>[0]; +interface ExpandedList { + data: ResponseRecord[]; + has_more: boolean; + object: "list"; +} +interface ResponseRecord { + data: Record<string, unknown>; + expanded?: Record<string, ExpandedList | null>; + id: string; +} +interface RecordList { + data: ResponseRecord[]; +} + +function queryRecords( + storageTarget: StorageTarget, + stream: string, + grant: unknown, + params: Record<string, unknown>, + manifest: Manifest +): Promise<RecordList> { + return queryRecordsUntyped(storageTarget, stream, grant as never, params, manifest as never) as Promise<RecordList>; +} + +function getRecord( + storageTarget: StorageTarget, + stream: string, + key: string, + grant: unknown, + manifest: Manifest, + params: Record<string, unknown> +): Promise<ResponseRecord> { + return getRecordUntyped( + storageTarget, + stream, + key, + grant as never, + manifest as never, + params + ) as Promise<ResponseRecord>; +} + +const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; + +function manifestFor(connectorId: string, includeNewRequiredFields: boolean): Manifest { + return { + capabilities: { human_interaction: [] }, + connector_id: connectorId, + display_name: "Expansion Instance Authorization Test", + manifest_uri: `https://sources.example/${connectorId}`, + protocol_version: "0.1.0", + streams: [ + { + name: "parents", + primary_key: ["id"], + query: { + expand: [{ default_limit: 10, max_limit: 20, name: "children" }, { name: "featured_child" }], + }, + relationships: [ + { + cardinality: "has_many", + foreign_key: "parent_id", + name: "children", + stream: "children", + }, + { + cardinality: "has_one", + foreign_key: "parent_id", + name: "featured_child", + stream: "children", + }, + ], + schema: { + properties: { + id: { type: "string" }, + newly_required_parent: { type: "string" }, + parent_id: { type: "string" }, + title: { type: "string" }, + }, + required: includeNewRequiredFields ? ["id", "newly_required_parent"] : ["id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + { + name: "children", + primary_key: ["id"], + schema: { + properties: { + "event-time": { format: "date-time", type: "string" }, + id: { type: "string" }, + newly_required_child: { type: "string" }, + parent_id: { type: "string" }, + visible: { type: "string" }, + }, + required: includeNewRequiredFields ? ["id", "parent_id", "newly_required_child"] : ["id", "parent_id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + ], + version: includeNewRequiredFields ? "2.0.0" : "1.0.0", + } as Manifest; +} + +async function seed(targetA: StorageTarget, targetB: StorageTarget): Promise<void> { + await ingestRecord(targetA, { + data: { id: "parent-1", newly_required_parent: "must not widen client output", title: "Parent" }, + key: "parent-1", + stream: "parents", + }); + await ingestRecord(targetA, { + data: { + "event-time": "2025-12-31T00:00:00Z", + id: "child-before-window", + newly_required_child: "must not widen client output", + parent_id: "parent-1", + visible: "before", + }, + key: "child-before-window", + stream: "children", + }); + await ingestRecord(targetA, { + data: { + "event-time": "2026-02-01T00:00:00Z", + id: "child-allowed", + newly_required_child: "must not widen client output", + parent_id: "parent-1", + visible: "allowed", + }, + key: "child-allowed", + stream: "children", + }); + await ingestRecord(targetA, { + data: { + "event-time": "2026-02-02T00:00:00Z", + id: "child-outside-resource-set", + newly_required_child: "must not widen client output", + parent_id: "parent-1", + visible: "not selected", + }, + key: "child-outside-resource-set", + stream: "children", + }); + await ingestRecord(targetB, { + data: { + "event-time": "2026-02-03T00:00:00Z", + id: "child-on-b", + newly_required_child: "B", + parent_id: "parent-1", + visible: "other connection", + }, + key: "child-on-b", + stream: "children", + }); +} + +async function runAuthorizationScenario(backend: "sqlite" | "postgres"): Promise<void> { + const suffix = `${backend}_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; + const connectorId = `expand_instance_${suffix}`; + const instanceA = `cin_${suffix}_a`; + const instanceB = `cin_${suffix}_b`; + const targetA = { connector_id: connectorId, connector_instance_id: instanceA }; + const targetB = { connector_id: connectorId, connector_instance_id: instanceB }; + const originalManifest = manifestFor(connectorId, false); + const manifest = manifestFor(connectorId, true); + + initDb(":memory:"); + if (backend === "postgres") { + assert.ok(POSTGRES_URL, "PostgreSQL URL must be configured"); + await initPostgresStorage({ backend: "postgres", databaseUrl: POSTGRES_URL }); + } + + try { + await registerConnector(originalManifest); + await seed(targetA, targetB); + + // The grants below represent authorization resolved against v1. The + // current declaration then adds required fields before either backend + // serves those frozen grants. + await registerConnector(manifest); + + const wrongChildInstanceGrant = { + streams: [ + { fields: ["id", "title"], instance_ids: [instanceA], name: "parents" }, + { fields: ["id", "parent_id", "visible"], instance_ids: [instanceB], name: "children" }, + ], + }; + await assert.rejects( + () => queryRecords(targetA, "parents", wrongChildInstanceGrant, { expand: "children" }, manifest), + (error: unknown) => + error instanceof Error && + (error as Error & { code?: string; param?: string }).code === "connection_not_found" && + (error as Error & { code?: string; param?: string }).param === "connection_id" + ); + await assert.rejects( + () => queryRecords(targetA, "parents", wrongChildInstanceGrant, { expand: "featured_child" }, manifest), + (error: unknown) => + error instanceof Error && + (error as Error & { code?: string; param?: string }).code === "connection_not_found" && + (error as Error & { code?: string; param?: string }).param === "connection_id" + ); + + const closedGrant = { + streams: [ + { fields: ["id", "title"], instance_ids: [instanceA], name: "parents" }, + { + fields: ["id", "parent_id", "visible"], + instance_ids: [instanceA], + name: "children", + resources: ["child-allowed"], + time_constraint: { field: "event-time", since: "2026-01-01T00:00:00Z" }, + }, + ], + }; + const page = await queryRecords(targetA, "parents", closedGrant, { expand: "children" }, manifest); + assert.equal(page.data.length, 1); + const [parent] = page.data; + assert.ok(parent); + assert.deepEqual(parent.data, { id: "parent-1", title: "Parent" }); + const children = parent.expanded?.children; + assert.ok(children); + assert.equal(children.object, "list"); + assert.equal(children.has_more, false); + assert.deepEqual( + children.data.map((row: { data: unknown; id: string }) => [row.id, row.data]), + [["child-allowed", { id: "child-allowed", parent_id: "parent-1", visible: "allowed" }]] + ); + + const detail = await getRecord(targetA, "parents", "parent-1", closedGrant, manifest, { + expand: "children", + }); + assert.deepEqual(detail.data, { id: "parent-1", title: "Parent" }); + const detailChildren = detail.expanded?.children; + assert.ok(detailChildren); + assert.deepEqual( + detailChildren.data.map((row: { data: unknown; id: string }) => [row.id, row.data]), + [["child-allowed", { id: "child-allowed", parent_id: "parent-1", visible: "allowed" }]] + ); + + // Owner grants omit instance_ids. They retain unrestricted self-read + // behavior, including fields in the current manifest declaration. + const ownerGrant = { streams: [{ name: "parents" }, { name: "children" }] }; + const ownerPage = await queryRecords(targetA, "parents", ownerGrant, { expand: "children" }, manifest); + const [ownerParent] = ownerPage.data; + assert.ok(ownerParent); + assert.equal(ownerParent.data.newly_required_parent, "must not widen client output"); + const ownerChildren = ownerParent.expanded?.children; + assert.ok(ownerChildren); + assert.equal(ownerChildren.data.length, 3); + const [ownerChild] = ownerChildren.data; + assert.ok(ownerChild); + assert.equal(ownerChild.data.newly_required_child, "must not widen client output"); + } finally { + if (backend === "postgres") { + try { + await postgresQuery("DELETE FROM record_changes WHERE connector_id = $1", [connectorId]); + await postgresQuery("DELETE FROM records WHERE connector_id = $1", [connectorId]); + await postgresQuery("DELETE FROM version_counter WHERE connector_id = $1", [connectorId]); + await postgresQuery("DELETE FROM connector_instances WHERE connector_id = $1", [connectorId]); + await postgresQuery("DELETE FROM connectors WHERE connector_id = $1", [connectorId]); + } finally { + await closePostgresStorage(); + } + } + closeDb(); + } +} + +test("SQLite expand enforces child instance scope and frozen declaration fields", async () => { + await runAuthorizationScenario("sqlite"); +}); + +test("PostgreSQL expand enforces child instance scope and frozen declaration fields", { + skip: POSTGRES_URL ? false : "PDPP_TEST_POSTGRES_URL is not set", +}, async () => { + await runAuthorizationScenario("postgres"); +}); diff --git a/reference-implementation/test/record-expand-temporal-authorization.test.ts b/reference-implementation/test/record-expand-temporal-authorization.test.ts new file mode 100644 index 000000000..079dcb255 --- /dev/null +++ b/reference-implementation/test/record-expand-temporal-authorization.test.ts @@ -0,0 +1,135 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { registerConnector } from "../server/auth.ts"; +import { closeDb, initDb } from "../server/db.ts"; +import { ingestRecord, queryRecords } from "../server/records.ts"; + +test("SQLite expansion enforces the child grant's frozen time_constraint in SQL", async () => { + const connectorId = `expand_time_${Date.now()}`; + const parentStream = "projects"; + const childStream = "events"; + const manifest = { + capabilities: { human_interaction: [] }, + connector_id: connectorId, + display_name: "Temporal Expand Test", + manifest_uri: `https://sources.example/${connectorId}`, + protocol_version: "0.1.0", + streams: [ + { + name: parentStream, + primary_key: ["id"], + query: { expand: [{ default_limit: 10, max_limit: 10, name: "events" }] }, + relationships: [{ cardinality: "has_many", foreign_key: "project_id", name: "events", stream: childStream }], + schema: { + properties: { id: { type: "string" }, name: { type: "string" } }, + required: ["id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + { + consent_time_field: "event-time", + cursor_field: "occurred_at", + name: childStream, + primary_key: ["id"], + schema: { + properties: { + "event-time": { format: "date-time", type: "string" }, + id: { type: "string" }, + mutable_time: { format: "date-time", type: "string" }, + occurred_at: { format: "date-time", type: "string" }, + project_id: { type: "string" }, + }, + required: ["id", "project_id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + ], + version: "1.0.0", + }; + + initDb(":memory:"); + try { + await registerConnector(manifest); + await ingestRecord(connectorId, { + data: { id: "project-1", name: "One" }, + key: "project-1", + stream: parentStream, + }); + const events = [ + { + "event-time": "2026-01-01T00:00:00Z", + id: "since", + mutable_time: "1999-01-01T00:00:00Z", + occurred_at: "2026-01-01T00:00:00Z", + }, + { + "event-time": "2026-01-02T00:00:00Z", + id: "inside", + mutable_time: "1999-01-01T00:00:00Z", + occurred_at: "2026-01-02T00:00:00Z", + }, + { + "event-time": "2026-01-03T00:00:00Z", + id: "until", + mutable_time: "2026-01-02T00:00:00Z", + occurred_at: "2026-01-03T00:00:00Z", + }, + { id: "missing", mutable_time: "2026-01-02T00:00:00Z", occurred_at: "2026-01-04T00:00:00Z" }, + { + "event-time": "not-a-time", + id: "malformed", + mutable_time: "2026-01-02T00:00:00Z", + occurred_at: "2026-01-05T00:00:00Z", + }, + ]; + await Promise.all( + events.map((event) => + ingestRecord(connectorId, { + data: { ...event, project_id: "project-1" }, + key: event.id, + stream: childStream, + }) + ) + ); + + const response = await queryRecords( + connectorId, + parentStream, + { + streams: [ + { fields: ["id", "name"], name: parentStream }, + { + fields: ["event-time", "id", "project_id"], + name: childStream, + time_constraint: { + field: "event-time", + since: "2026-01-01T00:00:00Z", + until: "2026-01-03T00:00:00Z", + }, + }, + ], + }, + { expand: "events" }, + manifest + ); + + const [project] = response.data as Array<{ + expanded?: { events?: { data: Array<{ id: string }> } }; + }>; + assert.ok(project?.expanded?.events); + assert.deepEqual( + project.expanded.events.data.map((event: { id: string }) => event.id), + ["since", "inside"] + ); + } finally { + closeDb(); + } +}); diff --git a/reference-implementation/test/record-field-window-substrate.test.ts b/reference-implementation/test/record-field-window-substrate.test.ts index fb9556abf..6951eba0e 100644 --- a/reference-implementation/test/record-field-window-substrate.test.ts +++ b/reference-implementation/test/record-field-window-substrate.test.ts @@ -82,6 +82,9 @@ const LONG_BODY = "The quick brown fox jumps over the lazy dog. ".repeat(300).tr const MANIFEST = { connector_id: CONNECTOR_ID, + display_name: "Record field-window substrate", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, + protocol_version: "0.1.0", streams: [ { consent_time_field: "created_at", @@ -100,12 +103,18 @@ const MANIFEST = { required: ["id"], type: "object", }, - selection: { fields: true }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", }; +const MUTATED_TIME_FIELD_MANIFEST = { + ...MANIFEST, + streams: MANIFEST.streams.map((stream) => ({ ...stream, consent_time_field: "subject" })), +}; + const SEED = [ { attachment: { blob_id: "blob_sha256_abc", kind: "blob", mime_type: "application/pdf" }, @@ -168,6 +177,20 @@ async function expectError(fn: () => Promise<unknown>, code: string, label: stri // The behavioral contract both backends must satisfy. async function runSubstrateConformance(label: string): Promise<void> { await seed(); + await Promise.all( + [ + { created_at: 0, id: "numeric-time" }, + { created_at: "not-a-time", id: "malformed-time" }, + { id: "missing-time" }, + ].map((temporal) => + ingestRecord(CONNECTOR_ID, { + data: { body: "must stay hidden", subject: "invalid time", ...temporal }, + emitted_at: "2026-01-01T00:00:00.000Z", + key: temporal.id, + stream: STREAM, + }) + ) + ); // 1. Default window from offset 0 returns a bounded prefix, reports the full // length, and signals more remains. @@ -285,20 +308,58 @@ async function runSubstrateConformance(label: string): Promise<void> { `${label}: ungranted stream` ); - // 11. Out-of-grant by time range -> not_found (the record's consent time is + // 11. Out-of-grant by frozen time constraint -> not_found (the record's time is // before the grant window, so the grant cannot see it at all). const futureGrant = { - streams: [{ fields: ["body"], name: STREAM, time_range: { since: "2026-03-01T00:00:00.000Z" } }], + streams: [ + { + fields: ["body"], + name: STREAM, + time_constraint: { field: "created_at", since: "2026-03-01T00:00:00.000Z" }, + }, + ], }; await expectError( - () => getRecordFieldWindow(CONNECTOR_ID, STREAM, "e1", "body", futureGrant, MANIFEST, {}), + () => getRecordFieldWindow(CONNECTOR_ID, STREAM, "e1", "body", futureGrant, MUTATED_TIME_FIELD_MANIFEST, {}), "not_found", - `${label}: record outside grant time range` + `${label}: record outside frozen grant time constraint` + ); + // ...but e2 (June) IS inside that window and reads fine even though the + // mutable manifest now points consent_time_field at a different field. + const wInRange = await getRecordFieldWindow( + CONNECTOR_ID, + STREAM, + "e2", + "body", + futureGrant, + MUTATED_TIME_FIELD_MANIFEST, + {} ); - // ...but e2 (June) IS inside that window and reads fine. - const wInRange = await getRecordFieldWindow(CONNECTOR_ID, STREAM, "e2", "body", futureGrant, MANIFEST, {}); assert.equal(wInRange.window.text, "short body", `${label}: in-range record reads under time grant`); + const coercionGrant = { + streams: [ + { + fields: ["body"], + name: STREAM, + time_constraint: { + field: "created_at", + since: "1999-01-01T00:00:00.000Z", + until: "2001-01-01T00:00:00.000Z", + }, + }, + ], + }; + await Promise.all( + ["numeric-time", "missing-time", "malformed-time"].map((recordId) => + expectError( + () => getRecordFieldWindow(CONNECTOR_ID, STREAM, recordId, "body", coercionGrant, MANIFEST, {}), + "not_found", + `${label}: ${recordId} fails closed under time constraint` + ) + ) + ); + // 12. Out-of-grant by resource list -> not_found. const resourceGrant = { streams: [{ fields: ["body"], name: STREAM, resources: ["e2"] }] }; await expectError( diff --git a/reference-implementation/test/record-filters-edge-branches.test.ts b/reference-implementation/test/record-filters-edge-branches.test.ts index 86a21669a..1af32565b 100644 --- a/reference-implementation/test/record-filters-edge-branches.test.ts +++ b/reference-implementation/test/record-filters-edge-branches.test.ts @@ -363,8 +363,8 @@ test("passesGrantRecordConstraints rejects a record key outside the resource all assert.equal(passesGrantRecordConstraints({}, "k1", grant, manifestStream()), true); }); -test("passesGrantRecordConstraints applies the grant time_range through the consent field", () => { - const grant = { time_range: { since: "2026-01-10T00:00:00Z" } }; +test("passesGrantRecordConstraints applies the frozen grant time_constraint field", () => { + const grant = { time_constraint: { field: "received_at", since: "2026-01-10T00:00:00Z" } }; const stream = manifestStream(); assert.equal(passesGrantRecordConstraints({ received_at: "2026-01-01T00:00:00Z" }, "k1", grant, stream), false); assert.equal(passesGrantRecordConstraints({ received_at: "2026-01-20T00:00:00Z" }, "k1", grant, stream), true); @@ -373,7 +373,7 @@ test("passesGrantRecordConstraints applies the grant time_range through the cons test("passesGrantRecordConstraints combines resource and time-range gates", () => { const grant = { resources: ["k1"], - time_range: { until: "2026-02-01T00:00:00Z" }, + time_constraint: { field: "received_at", until: "2026-02-01T00:00:00Z" }, }; const stream = manifestStream(); // In resource set AND before until -> allowed. diff --git a/reference-implementation/test/record-filters-predicates.test.ts b/reference-implementation/test/record-filters-predicates.test.ts index 022ab602d..65f553304 100644 --- a/reference-implementation/test/record-filters-predicates.test.ts +++ b/reference-implementation/test/record-filters-predicates.test.ts @@ -14,7 +14,7 @@ * malformed record JSON. * * The boundary assertions are the point: `gte` is inclusive, `gt` - * exclusive, `lte` inclusive, `lt` exclusive; `time_range.since` is + * exclusive, `lte` inclusive, `lt` exclusive; `time_constraint.since` is * inclusive, `until` exclusive. Off-by-one mutants (`<` vs `<=`) flip * exactly one of these and turn red here. */ @@ -180,8 +180,11 @@ test("passesGrantRecordConstraints: resources allowlist gates by record key, the assert.equal(passesGrantRecordConstraints({}, "k1", grant, stream), true); assert.equal(passesGrantRecordConstraints({}, "k9", grant, stream), false); - // Allowed key still subject to the grant time_range. - const timed = { resources: ["k1"], time_range: { since: "2026-02-01T00:00:00Z" } }; + // Allowed key still subject to the frozen grant time_constraint. + const timed = { + resources: ["k1"], + time_constraint: { field: "occurred_at", since: "2026-02-01T00:00:00Z" }, + }; assert.equal( passesGrantRecordConstraints({ occurred_at: "2026-01-01T00:00:00Z" }, "k1", timed, stream), false, @@ -190,13 +193,13 @@ test("passesGrantRecordConstraints: resources allowlist gates by record key, the assert.equal(passesGrantRecordConstraints({ occurred_at: "2026-03-01T00:00:00Z" }, "k1", timed, stream), true); }); -test("hasGrantRecordConstraints / needsCandidateRecordScan: detect time_range, non-empty resources, or filters", () => { +test("hasGrantRecordConstraints / needsCandidateRecordScan: detect time_constraint, resources, or filters", () => { assert.equal(hasGrantRecordConstraints(null), false); assert.equal(hasGrantRecordConstraints({}), false); // Empty resources array is NOT a constraint. assert.equal(hasGrantRecordConstraints({ resources: [] }), false); assert.equal(hasGrantRecordConstraints({ resources: ["k1"] }), true); - assert.equal(hasGrantRecordConstraints({ time_range: { since: "x" } }), true); + assert.equal(hasGrantRecordConstraints({ time_constraint: { field: "ts", since: "2026-01-01T00:00:00Z" } }), true); // needsCandidateRecordScan: true if any compiled filters OR grant constraints. assert.equal(needsCandidateRecordScan({}, []), false); diff --git a/reference-implementation/test/record-filters-runtime-evaluation.test.ts b/reference-implementation/test/record-filters-runtime-evaluation.test.ts index 013b8a4db..bd781cdc4 100644 --- a/reference-implementation/test/record-filters-runtime-evaluation.test.ts +++ b/reference-implementation/test/record-filters-runtime-evaluation.test.ts @@ -20,7 +20,9 @@ import { needsCandidateRecordScan, passesGrantRecordConstraints, passesRequestFilters, + passesTimeConstraint, passesTimeRange, + requireTimeConstraint, } from "../server/record-filters.ts"; const intSchema = { type: "integer" }; @@ -128,7 +130,7 @@ test("passesTimeRange: missing or unparseable field value is rejected", () => { assert.equal(passesTimeRange({ [field]: "not-a-date" }, tr, field), false, "NaN date rejected"); }); -// --- passesGrantRecordConstraints: resource allow-list + time_range ----------- +// --- passesGrantRecordConstraints: resource allow-list + frozen time --------- test("passesGrantRecordConstraints: resource allow-list gates by record key", () => { const grant = { resources: ["rec-a", "rec-b"] }; @@ -141,28 +143,67 @@ test("passesGrantRecordConstraints: empty resources means no key restriction", ( assert.equal(passesGrantRecordConstraints({}, "anything", {}, {}), true); }); -test("passesGrantRecordConstraints: also enforces grant time_range against consent_time_field", () => { - const grant = { time_range: { since: "2021-01-01T00:00:00Z" } }; - const manifestStream = { consent_time_field: "ts" }; +test("passesGrantRecordConstraints: enforces the frozen grant time_constraint field", () => { + const grant = { time_constraint: { field: "frozen_at", since: "2021-01-01T00:00:00Z" } }; + const manifestStream = { consent_time_field: "mutable_at" }; assert.equal( - passesGrantRecordConstraints({ ts: "2021-06-01T00:00:00Z" }, "k", grant, manifestStream), + passesGrantRecordConstraints({ frozen_at: "2021-06-01T00:00:00Z" }, "k", grant, manifestStream), true, "inside grant window passes" ); assert.equal( - passesGrantRecordConstraints({ ts: "2020-06-01T00:00:00Z" }, "k", grant, manifestStream), + passesGrantRecordConstraints({ frozen_at: "2020-06-01T00:00:00Z" }, "k", grant, manifestStream), false, "before grant since rejected" ); }); +test("time_constraint is since-inclusive, until-exclusive, and fails closed", () => { + const constraint = { + field: "frozen_at", + since: "2026-01-01T00:00:00Z", + until: "2026-02-01T00:00:00Z", + }; + assert.equal(passesTimeConstraint({ frozen_at: constraint.since }, constraint), true, "since is inclusive"); + assert.equal(passesTimeConstraint({ frozen_at: "2025-12-31T23:59:59Z" }, constraint), false); + assert.equal(passesTimeConstraint({ frozen_at: constraint.until }, constraint), false, "until is exclusive"); + assert.equal(passesTimeConstraint({}, constraint), false, "missing frozen field is not authorized"); + assert.equal(passesTimeConstraint({ frozen_at: "not-a-date" }, constraint), false, "bad record time is hidden"); + assert.throws( + () => requireTimeConstraint({ field: "frozen_at", since: 123 }), + (error: unknown) => error instanceof Error && "code" in error && error.code === "grant_invalid" + ); + assert.throws( + () => requireTimeConstraint({ field: "frozen_at", since: "not-a-date" }), + (error: unknown) => error instanceof Error && "code" in error && error.code === "grant_invalid" + ); + assert.deepEqual(requireTimeConstraint({ field: "nested.time", since: "2026-01-01T00:00:00Z" }), { + field: "nested.time", + since: "2026-01-01T00:00:00Z", + }); +}); + +test("time_constraint preserves arbitrary literal top-level fields", () => { + for (const field of [" leading and trailing ", "event-time", "occurred.at", 'said "when"', "時刻"]) { + const constraint = requireTimeConstraint({ + field, + since: "2026-01-01T00:00:00Z", + }); + assert.deepEqual(constraint, { + field, + since: "2026-01-01T00:00:00Z", + }); + assert.equal(passesTimeConstraint({ [field]: "2026-01-02T00:00:00Z" }, constraint), true, field); + } +}); + // --- hasGrantRecordConstraints / needsCandidateRecordScan --------------------- -test("hasGrantRecordConstraints: true only when time_range or non-empty resources", () => { +test("hasGrantRecordConstraints: true only when time_constraint or non-empty resources", () => { assert.equal(hasGrantRecordConstraints({}), false); assert.equal(hasGrantRecordConstraints({ resources: [] }), false, "empty resources is NOT a constraint"); assert.equal(hasGrantRecordConstraints({ resources: ["x"] }), true); - assert.equal(hasGrantRecordConstraints({ time_range: { since: "x" } }), true); + assert.equal(hasGrantRecordConstraints({ time_constraint: { field: "ts", since: "2026-01-01T00:00:00Z" } }), true); }); test("needsCandidateRecordScan: true when filters present OR grant constrains records", () => { diff --git a/reference-implementation/test/record-synthesis-field-names.test.ts b/reference-implementation/test/record-synthesis-field-names.test.ts new file mode 100644 index 000000000..9ec6eec0d --- /dev/null +++ b/reference-implementation/test/record-synthesis-field-names.test.ts @@ -0,0 +1,34 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { deriveCursorValue } from "../scripts/migrate-storage/record-synthesis.ts"; + +const LITERAL_FIELDS = [" leading and trailing ", "event-time", "occurred.at", 'said "when"', "時刻"]; + +test("migration cursor synthesis reads arbitrary literal top-level field names", () => { + for (const [index, field] of LITERAL_FIELDS.entries()) { + const value = `2026-01-0${index + 1}T00:00:00.000Z`; + const stream = { cursor_field: field }; + assert.equal(deriveCursorValue(stream, { [field]: value }), value, field); + assert.equal(deriveCursorValue(stream, JSON.stringify({ [field]: value })), value, field); + } +}); + +test("migration cursor synthesis treats dotted names as literal keys", () => { + assert.equal( + deriveCursorValue( + { cursor_field: "occurred.at" }, + JSON.stringify({ occurred: { at: "nested" }, "occurred.at": "literal" }) + ), + "literal" + ); +}); + +test("migration cursor synthesis returns null only for absent, null, or empty cursor fields", () => { + assert.equal(deriveCursorValue({ cursor_field: "event-time" }, {}), null); + assert.equal(deriveCursorValue({ cursor_field: "event-time" }, { "event-time": null }), null); + assert.equal(deriveCursorValue({ cursor_field: "" }, { "": "not declared" }), null); +}); diff --git a/reference-implementation/test/record-window-count-parity.test.ts b/reference-implementation/test/record-window-count-parity.test.ts index 1c4d52231..02abc27ce 100644 --- a/reference-implementation/test/record-window-count-parity.test.ts +++ b/reference-implementation/test/record-window-count-parity.test.ts @@ -48,6 +48,9 @@ const CONNECTOR_ID = "window_parity_demo"; const STREAM = "items"; const MANIFEST = { connector_id: CONNECTOR_ID, + display_name: "Record window count parity", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, + protocol_version: "0.1.0", streams: [ { consent_time_field: "created_at", @@ -62,7 +65,8 @@ const MANIFEST = { }, type: "object", }, - selection: { fields: true }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", diff --git a/reference-implementation/test/records-instance-namespace.test.ts b/reference-implementation/test/records-instance-namespace.test.ts index aa2a54dac..95c609c08 100644 --- a/reference-implementation/test/records-instance-namespace.test.ts +++ b/reference-implementation/test/records-instance-namespace.test.ts @@ -98,6 +98,7 @@ const manifest = { capabilities: { human_interaction: [] }, connector_id: CONNECTOR_ID, display_name: "Instance Records", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, protocol_version: "0.1.0", streams: [ { @@ -112,6 +113,8 @@ const manifest = { required: ["id", "subject"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -330,12 +333,9 @@ test("semantic candidate planning scans connector instance namespace, not connec streams: [ { fields: ["id", "subject"], + instance_ids: [WORK_INSTANCE_ID], name: STREAM, resources: ["same-key"], - time_range: { - since: "2026-05-18T00:00:00.000Z", - until: "2026-05-19T00:00:00.000Z", - }, }, ], }, diff --git a/reference-implementation/test/records-limit-clamp.test.ts b/reference-implementation/test/records-limit-clamp.test.ts index d51d09f62..ee6f29d9a 100644 --- a/reference-implementation/test/records-limit-clamp.test.ts +++ b/reference-implementation/test/records-limit-clamp.test.ts @@ -86,6 +86,7 @@ const manifest = { capabilities: { human_interaction: [] }, connector_id: CONNECTOR_ID, display_name: "Limit-clamp Test Connector", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, protocol_version: "0.1.0", streams: [ { @@ -101,6 +102,8 @@ const manifest = { required: ["id", "subject", "received_at"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -293,6 +296,7 @@ test("multi-connection fan-in surfaces a single deduplicated limit_clamped warni // warning), not one per connection. await withDualConnectionDb(80, async () => { const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A, INSTANCE_B], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); diff --git a/reference-implementation/test/records-meta-window.test.ts b/reference-implementation/test/records-meta-window.test.ts index c694adf4e..854610e27 100644 --- a/reference-implementation/test/records-meta-window.test.ts +++ b/reference-implementation/test/records-meta-window.test.ts @@ -117,6 +117,7 @@ const baseManifest = { capabilities: { human_interaction: [] }, connector_id: CONNECTOR_ID, display_name: "Meta-window Test Connector", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, protocol_version: "0.1.0", streams: [ { @@ -138,6 +139,8 @@ const baseManifest = { required: ["id", "subject", "received_at"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -151,6 +154,7 @@ const noTimeManifest = { capabilities: { human_interaction: [] }, connector_id: NO_TIME_CONNECTOR_ID, display_name: "No-time Test Connector", + manifest_uri: `https://sources.example/${NO_TIME_CONNECTOR_ID}`, protocol_version: "0.1.0", streams: [ { @@ -164,6 +168,8 @@ const noTimeManifest = { required: ["id", "subject"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -359,14 +365,14 @@ test("a request filter narrows total and tightens the window bounds", async () = }); }); -test("a grant time_range narrows total and tightens the window bounds", async () => { +test("a frozen grant time_constraint narrows total and tightens the window bounds", async () => { await withSeededDb(async () => { const narrowedGrant = { streams: [ { fields: ["id", "subject", "amount", "received_at"], name: STREAM, - time_range: { since: "2023-01-01T00:00:00.000Z" }, + time_constraint: { field: "received_at", since: "2023-01-01T00:00:00.000Z" }, }, ], }; @@ -624,6 +630,7 @@ async function withDualWindowDb(testFn: () => Promise<void>, { recordsB }: DualW test("fan-in merges all-present windows: total sums, bounds are global min/max", async () => { await withDualWindowDb(async () => { const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A, INSTANCE_B], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); @@ -641,6 +648,7 @@ test("fan-in omits the merged window when one binding cannot produce bounds", as await withDualWindowDb( async () => { const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A, INSTANCE_B], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); @@ -664,6 +672,7 @@ test("fan-in omits the merged window when one binding cannot produce bounds", as test("fan-in single-binding path passes meta.window through unchanged", async () => { await withSeededDb(async () => { const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); @@ -681,6 +690,7 @@ test("fan-in single-binding path passes meta.window through unchanged", async () test("fan-in without the window param omits meta.window", async () => { await withDualWindowDb(async () => { const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A, INSTANCE_B], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); diff --git a/reference-implementation/test/records-nullable-cursor.test.ts b/reference-implementation/test/records-nullable-cursor.test.ts index d2766c116..714044fb0 100644 --- a/reference-implementation/test/records-nullable-cursor.test.ts +++ b/reference-implementation/test/records-nullable-cursor.test.ts @@ -177,6 +177,7 @@ function nullableDateTimeManifest() { return { connector_id: "nullable-datetime", display_name: "Nullable DateTime Cursor", + manifest_uri: "https://sources.example/nullable-datetime", protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ @@ -211,6 +212,7 @@ function nullableIntegerManifest() { return { connector_id: "nullable-integer", display_name: "Nullable Integer Cursor", + manifest_uri: "https://sources.example/nullable-integer", protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ @@ -242,6 +244,7 @@ function unsupportedPlainStringManifest() { return { connector_id: "plain-string-cursor", display_name: "Plain String Cursor", + manifest_uri: "https://sources.example/plain-string-cursor", protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ diff --git a/reference-implementation/test/records-nullable-filters.test.ts b/reference-implementation/test/records-nullable-filters.test.ts index 2ef2c7bc9..ce18ea1e8 100644 --- a/reference-implementation/test/records-nullable-filters.test.ts +++ b/reference-implementation/test/records-nullable-filters.test.ts @@ -173,6 +173,7 @@ function nullableFiltersManifest() { return { connector_id: "nullable-filters", display_name: "Nullable Filters", + manifest_uri: "https://sources.example/nullable-filters", protocol_version: "0.1.0", runtime_requirements: { bindings: { network: { required: true } } }, streams: [ diff --git a/reference-implementation/test/ref-approval-detail-operation.test.ts b/reference-implementation/test/ref-approval-detail-operation.test.ts new file mode 100644 index 000000000..009161433 --- /dev/null +++ b/reference-implementation/test/ref-approval-detail-operation.test.ts @@ -0,0 +1,161 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildConsentApprovalDetail, + executeRefApprovalDetail, + type RefApprovalConsentDetail, + type RefApprovalDetail, +} from "../operations/ref-approval-detail/index.ts"; + +const FORBIDDEN_DEVICE_CODE_RE = /forbidden device_code/; +const FORBIDDEN_AUTHORIZATION_RE = /forbidden Authorization/; + +function detail(): RefApprovalConsentDetail { + return { + approval_id: "apr_review", + client: { + client_id: "concert_finder", + display: { + name: "Concert Finder", + policy_uri: "https://app.example/policy", + tos_uri: null, + uri: "https://app.example", + }, + registration_mode: "pre_registered_public", + }, + created_at: "2026-08-11T12:00:00.000Z", + expires_at: "2026-08-11T12:10:00.000Z", + grant_outcome: { + access_mode: "continuous", + description: "Ongoing access; this reference implementation sets no grant expiry.", + }, + kind: "consent", + object: "approval_review", + purpose: { code: "https://pdpp.org/purpose/personalization", description: "Suggest concerts." }, + retention: { period: "P30D" }, + source: { id: "spotify", kind: "connector" }, + streams: [ + { + client_claims: { use: "recommendations" }, + connection_id: "cin_music", + fields: ["name"], + name: "top_artists", + necessity: null, + resources: ["saved"], + time_range: { since: "2026-01-01" }, + view: "basic", + }, + ], + trust: "unverified", + }; +} + +test("ref.approvals.detail returns the complete allowlisted review projection", async () => { + const projected = await executeRefApprovalDetail({ getPendingApprovalDetail: detail }); + assert.deepEqual(projected, detail()); +}); + +test("ref.approvals.detail returns null for a terminal or expired approval", async () => { + const projected = await executeRefApprovalDetail({ getPendingApprovalDetail: () => null }); + assert.equal(projected, null); +}); + +test("ref.approvals.detail rejects a dependency that leaks a raw pending-row secret", async () => { + const leaked: RefApprovalDetail = { + ...detail(), + // @ts-expect-error -- deliberate off-contract leak verifies the runtime redaction guard + device_code: "dc_bearer_equivalent", + }; + await assert.rejects(executeRefApprovalDetail({ getPendingApprovalDetail: () => leaked }), FORBIDDEN_DEVICE_CODE_RE); +}); + +test("ref.approvals.detail drops unsafe registered display URIs before rendering", () => { + const projected = buildConsentApprovalDetail( + { + approval_id: "apr_links", + created_at: "2026-08-11T12:00:00.000Z", + expires_at: "2026-08-11T12:10:00.000Z", + }, + { + client: { + client_display: { + name: "Link Tester", + policy_uri: "https://policy.example/privacy", + tos_uri: "https://user:pass@terms.example/tos", + uri: "javascript:alert(1)", + }, + client_id: "link_tester", + registration_mode: "dynamic_public", + }, + selection: { access_mode: "single_use" }, + }, + [] + ); + + assert.ok(projected); + assert.equal(projected.client.display.uri, null); + assert.equal(projected.client.display.policy_uri, "https://policy.example/privacy"); + assert.equal(projected.client.display.tos_uri, null); +}); + +test("ref.approvals.detail recursively strips common credential-shaped JSON names", () => { + const projected = buildConsentApprovalDetail( + { + approval_id: "apr_secret_json", + created_at: "2026-08-11T12:00:00.000Z", + expires_at: "2026-08-11T12:10:00.000Z", + }, + { + client: { client_id: "secret_tester" }, + selection: { + access_mode: "continuous", + retention: { + nested: { + Authorization: "Bearer bearer-value", + client_secret: "client-secret-value", + clientSecret: "client-secret-value", + notes: "safe", + }, + }, + }, + }, + [ + { + client_claims: { + audit: [{ refresh_token: "refresh-value" }, { ordinary_tokenized_label: "safe label" }], + safe: "claim", + }, + name: "events", + resources: [{ "api-key": "api-key-value", id: "record_1" }], + }, + ] + ); + + assert.ok(projected); + assert.deepEqual(projected.retention, { + nested: { notes: "safe" }, + }); + assert.deepEqual(projected.streams[0]?.client_claims, { + audit: [{}, { ordinary_tokenized_label: "safe label" }], + safe: "claim", + }); + assert.deepEqual(projected.streams[0]?.resources, [{ id: "record_1" }]); +}); + +test("ref.approvals.detail rejects nested credential-shaped dependency leaks case-insensitively", async () => { + const leaked: RefApprovalDetail = { + ...detail(), + retention: { + nested: { + Authorization: "Bearer bearer-value", + }, + }, + }; + await assert.rejects( + executeRefApprovalDetail({ getPendingApprovalDetail: () => leaked }), + FORBIDDEN_AUTHORIZATION_RE + ); +}); diff --git a/reference-implementation/test/ref-approvals-list-operation.test.ts b/reference-implementation/test/ref-approvals-list-operation.test.ts index 656ee9d0d..8f0ad814a 100644 --- a/reference-implementation/test/ref-approvals-list-operation.test.ts +++ b/reference-implementation/test/ref-approvals-list-operation.test.ts @@ -31,6 +31,7 @@ function makeConsent( ): RefApprovalConsent { return { approval_id: approvalId, + batch: false, client_id: "client_x", created_at: createdAt, grant_preview: { @@ -163,3 +164,11 @@ test("ref.approvals.list awaits dependency promises", async () => { assert.equal(resolved, true); assert.equal(envelope.data.length, 1); }); + +test("ref.approvals.list preserves the non-secret batch ceremony discriminator", async () => { + const envelope = await executeRefApprovalsList({ + listPendingApprovals: () => [makeConsent("batch", "2026-04-01T00:00:00Z", { batch: true })], + }); + assert.equal(envelope.data[0]?.kind, "consent"); + assert.equal(envelope.data[0]?.batch, true); +}); diff --git a/reference-implementation/test/ref-fleet-health-route.test.ts b/reference-implementation/test/ref-fleet-health-route.test.ts index 43680fe1e..e86d27e3e 100644 --- a/reference-implementation/test/ref-fleet-health-route.test.ts +++ b/reference-implementation/test/ref-fleet-health-route.test.ts @@ -18,6 +18,7 @@ import type { MountRefConnectorsContext } from "../server/routes/ref-connectors. import { mountRefFleetHealth } from "../server/routes/ref-connectors.ts"; import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; import { createApp } from "../server/transport.ts"; +import { TEST_INTROSPECTION_SERVER_OPTS } from "./helpers/introspection-test-credentials.ts"; // mountRefFleetHealth only reads getFleetHealthVerdict / handleError / // requireOwnerSession from the shared MountRefConnectorsContext, but the @@ -194,7 +195,11 @@ test("production fleet wiring projects one custom-owner visible population witho }); } - const app = buildAsApp({ ownerAuthPassword: "", ownerAuthSubjectId: CUSTOM_OWNER_SUBJECT_ID }); + const app = buildAsApp({ + ownerAuthPassword: "", + ownerAuthSubjectId: CUSTOM_OWNER_SUBJECT_ID, + ...TEST_INTROSPECTION_SERVER_OPTS, + }); await app.fastify.ready(); try { const response = await app.fastify.inject({ method: "GET", url: "/_ref/fleet-health" }); diff --git a/reference-implementation/test/ref-grant-packages.test.ts b/reference-implementation/test/ref-grant-packages.test.ts index cf4dca89c..40a8e0111 100644 --- a/reference-implementation/test/ref-grant-packages.test.ts +++ b/reference-implementation/test/ref-grant-packages.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 /** - * `_ref/grant-packages` operator visibility surface — owner-session-gated + * `_ref/grant-packages` operator visibility surface: owner-session-gated * list, detail, and revoke endpoints introduced by the OpenSpec change * `add-grant-package-operator-visibility`. * @@ -13,7 +13,7 @@ * 1. `GET /_ref/grant-packages` lists the package with member count * and exposes no token/secret material. * 2. `GET /_ref/grant-packages/:id` returns the child cascade with - * `grant_id`, `grant_status`, `source`, and timestamps — and never + * `grant_id`, `grant_status`, `source`, and timestamps, and never * includes secret fields. * 3. `GET /_ref/grant-packages/:id` returns a typed `not_found` 404 * envelope for unknown ids. @@ -38,11 +38,13 @@ import { fileURLToPath } from "node:url"; import { canonicalConnectorKeyFromManifest } from "../server/connector-key.ts"; import { getDb } from "../server/db.ts"; -import { encodeHostedMcpSelection } from "../server/hosted-mcp-selection.ts"; import { startServer as startServerUntyped } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); +const OWNER_SUBJECT_ID = "owner_local"; +const NOW = "2026-05-31T00:00:00.000Z"; const SECRET_KEYS = new Set([ "access_token", @@ -59,7 +61,7 @@ const SECRET_KEYS = new Set([ * TypeScript's `allowJs`-without-`checkJs` inference on `startServer`'s * default-valued `opts = {}` parameter collapses to `{}`, and its returned * `asServer`/`rsServer` infer as `Http2SecureServer` (missing - * `closeAllConnections`) — both are inference artifacts of the untyped + * `closeAllConnections`), both are inference artifacts of the untyped * source, not the real runtime shape (real `http.Server` instances from * `asApp.listen(...)` / `rsApp.listen(...)`). Mirrors the same pattern used * in test/ref-client-event-subscriptions-routes.test.ts. @@ -211,7 +213,7 @@ function assertNoSecretMaterial(value: unknown, path = "$"): void { for (const [key, v] of Object.entries(value)) { assert.ok( !SECRET_KEYS.has(key), - `secret-shaped field "${key}" surfaced at ${path}.${key} — operator surfaces must not leak token material` + `secret-shaped field "${key}" surfaced at ${path}.${key}: operator surfaces must not leak token material` ); assertNoSecretMaterial(v, `${path}.${key}`); } @@ -227,6 +229,16 @@ function renderedHostedMcpStreamValues(html: string): string[] { }); } +function renderedHostedMcpSourceValues(html: string): string[] { + return [ + ...html.matchAll(/<input[^>]*name="selection"[^>]*value="([^"]+)"[^>]*data-hosted-mcp-source-checkbox[^>]*>/g), + ].map((match) => { + const [, value] = match; + assert.ok(value, "source checkbox input must carry a value attribute"); + return value; + }); +} + async function closeServer(server: TestServerHandle): Promise<void> { server.asServer.closeAllConnections(); server.rsServer.closeAllConnections(); @@ -267,9 +279,25 @@ async function registerConnector(asUrl: string, name: string): Promise<Connector method: "POST", }); assert.equal(status, 201); + await seedConnectorInstance(manifest, name); return manifest; } +async function seedConnectorInstance(manifest: ConnectorManifestFixture, name: string): Promise<void> { + await createSqliteConnectorInstanceStore().upsert({ + connectorId: manifest.connector_id, + connectorInstanceId: `cin_ref_grant_packages_${name}`, + createdAt: NOW, + displayName: `Ref Grant Packages ${name}`, + ownerSubjectId: OWNER_SUBJECT_ID, + sourceBinding: { account_hint: `${name}@ref-grant-packages.example.com` }, + sourceBindingKey: `${name}@ref-grant-packages.example.com`, + sourceKind: "account", + status: "active", + updatedAt: NOW, + }); +} + async function registerAuthCodeClient(asUrl: string): Promise<RegisteredClient> { const { status, body } = await fetchJson(`${asUrl}/oauth/register`, { body: JSON.stringify({ @@ -321,8 +349,12 @@ async function completeMultiSourcePackageFlow({ params.append("state", state); params.append("code_challenge", challenge); params.append("code_challenge_method", "S256"); - for (const id of connectorIds) { - params.append("selection", encodeHostedMcpSelection({ connectionId: null, connectorId: id })); + const selectedConnectorIds = new Set(connectorIds); + for (const sourceValue of renderedHostedMcpSourceValues(pickerHtml)) { + const decoded = JSON.parse(Buffer.from(sourceValue, "base64url").toString("utf8")) as { connector_id?: string }; + if (decoded.connector_id && selectedConnectorIds.has(decoded.connector_id)) { + params.append("selection", sourceValue); + } } // Mirror explicit whole-source approval: submit every stream value for the // selected sources. Narrowing cases construct their own form submissions. @@ -336,7 +368,8 @@ async function completeMultiSourcePackageFlow({ method: "POST", redirect: "manual", }); - assert.equal(approveResp.status, 302); + const approveBody = await approveResp.clone().text(); + assert.equal(approveResp.status, 302, approveBody); const location = approveResp.headers.get("location"); assert.ok(location, "approve response must carry a redirect location"); const callback = new URL(location); diff --git a/reference-implementation/test/ref-read-owner-gate.test.ts b/reference-implementation/test/ref-read-owner-gate.test.ts index 2fb95b711..d5cfd822e 100644 --- a/reference-implementation/test/ref-read-owner-gate.test.ts +++ b/reference-implementation/test/ref-read-owner-gate.test.ts @@ -47,6 +47,7 @@ const REF_READ_ROUTES = [ "/_ref/connectors", "/_ref/connectors/connector_does_not_exist", "/_ref/approvals", + "/_ref/approvals/apr_does_not_exist", "/_ref/records/timeline", "/_ref/schedules", "/_ref/connectors/connector_does_not_exist/schedule", diff --git a/reference-implementation/test/rs-client-event-derive-operation.test.ts b/reference-implementation/test/rs-client-event-derive-operation.test.ts index ce0ea0d6c..82c2d2e4b 100644 --- a/reference-implementation/test/rs-client-event-derive-operation.test.ts +++ b/reference-implementation/test/rs-client-event-derive-operation.test.ts @@ -25,8 +25,11 @@ function activeSub(overrides: Partial<ActiveSubscription> = {}): ActiveSubscript return { ...baseSub, scope: { - source: { id: "gmail", kind: "connector" }, - streams: [{ name: "messages" }, { name: "contacts" }], + source: { connector_id: "gmail", id: "https://registry.pdpp.org/connectors/gmail", kind: "connector" }, + streams: [ + { instance_ids: ["gmail_default"], name: "messages" }, + { instance_ids: ["gmail_default"], name: "contacts" }, + ], }, ...overrides, }; @@ -108,7 +111,13 @@ test("derive isolates trusted owner-agent wildcard subscriptions by owner subjec test("derive omits envelope for streams outside grant scope", () => { const events = deriveClientEventsFromRecordChange( - { connectorId: "gmail", connectorInstanceId: "g", emittedAt: "now", stream: "labels", version: 1 }, + { + connectorId: "gmail", + connectorInstanceId: "gmail_default", + emittedAt: "now", + stream: "labels", + version: 1, + }, [activeSub()] ); assert.equal(events.length, 0); @@ -118,33 +127,49 @@ test("derive respects client-narrowed filters subset", () => { const sub = activeSub({ scope: { filters: { streams: ["messages"] }, - source: { id: "gmail", kind: "connector" }, - streams: [{ name: "messages" }, { name: "contacts" }], + source: { connector_id: "gmail", id: "https://registry.pdpp.org/connectors/gmail", kind: "connector" }, + streams: [ + { instance_ids: ["gmail_default"], name: "messages" }, + { instance_ids: ["gmail_default"], name: "contacts" }, + ], }, }); const eventsMsgs = deriveClientEventsFromRecordChange( - { connectorId: "gmail", connectorInstanceId: "g", emittedAt: "now", stream: "messages", version: 1 }, + { + connectorId: "gmail", + connectorInstanceId: "gmail_default", + emittedAt: "now", + stream: "messages", + version: 1, + }, [sub] ); const eventsContacts = deriveClientEventsFromRecordChange( - { connectorId: "gmail", connectorInstanceId: "g", emittedAt: "now", stream: "contacts", version: 2 }, + { + connectorId: "gmail", + connectorInstanceId: "gmail_default", + emittedAt: "now", + stream: "contacts", + version: 2, + }, [sub] ); assert.equal(eventsMsgs.length, 1); assert.equal(eventsContacts.length, 0); }); -test("derive matches connection_id when grant binds one", () => { +test("derive enforces source and instance_ids from the closed grant", () => { const sub = activeSub({ scope: { - streams: [{ connection_id: "conn_work", name: "messages" }], + source: { connector_id: "gmail", id: "https://registry.pdpp.org/connectors/gmail", kind: "connector" }, + streams: [{ instance_ids: ["conn_work"], name: "messages" }], }, }); const matches = deriveClientEventsFromRecordChange( { connectionId: "conn_work", connectorId: "gmail", - connectorInstanceId: "g", + connectorInstanceId: "conn_work", emittedAt: "now", stream: "messages", version: 1, @@ -155,7 +180,7 @@ test("derive matches connection_id when grant binds one", () => { { connectionId: "conn_personal", connectorId: "gmail", - connectorInstanceId: "g", + connectorInstanceId: "conn_personal", emittedAt: "now", stream: "messages", version: 1, @@ -167,6 +192,63 @@ test("derive matches connection_id when grant binds one", () => { assert.ok(match); assert.equal(match.data.connection_id, "conn_work"); assert.equal(otherConn.length, 0); + + const otherSource = deriveClientEventsFromRecordChange( + { + connectionId: "conn_work", + connectorId: "outlook", + connectorInstanceId: "conn_work", + emittedAt: "now", + stream: "messages", + version: 1, + }, + [sub] + ); + assert.equal(otherSource.length, 0); +}); + +test("derive enforces resource and time constraints before emitting a hint", () => { + const sub = activeSub({ + scope: { + source: { connector_id: "gmail", id: "https://registry.pdpp.org/connectors/gmail", kind: "connector" }, + streams: [ + { + instance_ids: ["gmail_default"], + name: "messages", + resources: ["message-1"], + time_constraint: { field: "sent_at", since: "2026-01-01T00:00:00Z" }, + }, + ], + }, + }); + const baseChange = { + connectorId: "gmail", + connectorInstanceId: "gmail_default", + emittedAt: "now", + stream: "messages", + version: 1, + } as const; + assert.equal( + deriveClientEventsFromRecordChange( + { ...baseChange, data: { sent_at: "2026-02-01T00:00:00Z" }, recordKey: "message-1" }, + [sub] + ).length, + 1 + ); + assert.equal( + deriveClientEventsFromRecordChange( + { ...baseChange, data: { sent_at: "2026-02-01T00:00:00Z" }, recordKey: "message-2" }, + [sub] + ).length, + 0 + ); + assert.equal( + deriveClientEventsFromRecordChange( + { ...baseChange, data: { sent_at: "2025-12-01T00:00:00Z" }, recordKey: "message-1" }, + [sub] + ).length, + 0 + ); }); test("derive ignores non-active subscriptions", () => { @@ -176,7 +258,13 @@ test("derive ignores non-active subscriptions", () => { // deliberately passes a non-"active" status to prove that runtime guard works. const sub = activeSub({ status: "pending_verification" }); const events = deriveClientEventsFromRecordChange( - { connectorId: "gmail", connectorInstanceId: "g", emittedAt: "now", stream: "messages", version: 1 }, + { + connectorId: "gmail", + connectorInstanceId: "gmail_default", + emittedAt: "now", + stream: "messages", + version: 1, + }, [sub] ); assert.equal(events.length, 0); @@ -184,7 +272,13 @@ test("derive ignores non-active subscriptions", () => { test("derive output carries no record body or field values", () => { const events = deriveClientEventsFromRecordChange( - { connectorId: "gmail", connectorInstanceId: "g", emittedAt: "now", stream: "messages", version: 1 }, + { + connectorId: "gmail", + connectorInstanceId: "gmail_default", + emittedAt: "now", + stream: "messages", + version: 1, + }, [activeSub()] ); const [event] = events; diff --git a/reference-implementation/test/rs-explore-timeline-conformance.test.ts b/reference-implementation/test/rs-explore-timeline-conformance.test.ts index 462666c92..9302335ee 100644 --- a/reference-implementation/test/rs-explore-timeline-conformance.test.ts +++ b/reference-implementation/test/rs-explore-timeline-conformance.test.ts @@ -43,12 +43,12 @@ const SUFFIX = `${Date.now()}_${Math.floor(Math.random() * 1e6)}`; // Two (connector_instance_id, stream) partitions across two "connections". const PARTITION_A = { - connectorId: `explore_c1_${SUFFIX}`, + connectorId: `explore-c1-${SUFFIX}`, connectorInstanceId: `explore_cin1_${SUFFIX}`, stream: "orders", }; const PARTITION_B = { - connectorId: `explore_c2_${SUFFIX}`, + connectorId: `explore-c2-${SUFFIX}`, connectorInstanceId: `explore_cin2_${SUFFIX}`, stream: "transactions", }; @@ -653,7 +653,9 @@ if (POSTGRES_URL) { // after. Runs on SQLite and (when PDPP_TEST_POSTGRES_URL is set) Postgres. const SEM_SUFFIX = `${Date.now()}_${Math.floor(Math.random() * 1e6)}`; -const SEM_CONNECTOR_ID = `explore_sem_${SEM_SUFFIX}`; +const SEM_CONNECTOR_ID = `explore-sem-${SEM_SUFFIX}`; +const SEM_CONNECTOR_KEY = `explore-sem-${SEM_SUFFIX}`; +const SEM_MANIFEST_URI = `https://test.pdpp.org/connectors/explore-sem-${SEM_SUFFIX}`; const SEM_INSTANCE_ID = `explore_sem_cin_${SEM_SUFFIX}`; const SEM_STREAM = "conversations"; @@ -662,7 +664,9 @@ const SEM_STREAM = "conversations"; const SEM_MANIFEST = { capabilities: { human_interaction: [] }, connector_id: SEM_CONNECTOR_ID, + connector_key: SEM_CONNECTOR_KEY, display_name: "Explore Semantic-Time Test Connector", + manifest_uri: SEM_MANIFEST_URI, protocol_version: "0.1.0", streams: [ { @@ -679,6 +683,8 @@ const SEM_MANIFEST = { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "append_only", }, ], version: "1.0.0", diff --git a/reference-implementation/test/rs-ingest-systemic-redaction-route.test.ts b/reference-implementation/test/rs-ingest-systemic-redaction-route.test.ts index 460496aab..7cadcb197 100644 --- a/reference-implementation/test/rs-ingest-systemic-redaction-route.test.ts +++ b/reference-implementation/test/rs-ingest-systemic-redaction-route.test.ts @@ -15,6 +15,7 @@ import { createSqliteConnectorInstanceStore } from "../server/stores/connector-i import { writeSqliteRunHistoryForSpineEvent } from "../server/stores/run-history-writer.ts"; const CONNECTOR_ID = "systemic_redaction_route_probe"; +const CONNECTOR_URI = "https://registry.pdpp.dev/connectors/systemic-redaction-route-probe"; const CONNECTOR_INSTANCE_ID = "cin_systemic_redaction_route_probe"; const INTERNAL_RUN_ID = "run_internal_secret_storage_detail"; const NOW = "2026-08-13T00:00:00.000Z"; @@ -65,7 +66,9 @@ function freshDb(t: TestContext): void { function manifest() { return { connector_id: CONNECTOR_ID, + connector_key: CONNECTOR_ID, display_name: "Systemic redaction route probe", + manifest_uri: CONNECTOR_URI, protocol_version: "0.1.0", streams: [ { @@ -76,6 +79,8 @@ function manifest() { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", diff --git a/reference-implementation/test/rs-record-field-window-route.test.ts b/reference-implementation/test/rs-record-field-window-route.test.ts index 30b13ee02..27386ebec 100644 --- a/reference-implementation/test/rs-record-field-window-route.test.ts +++ b/reference-implementation/test/rs-record-field-window-route.test.ts @@ -40,12 +40,42 @@ const LONG_BODY = "The quick brown fox jumps over the lazy dog. ".repeat(300).tr const CONNECTOR_ID = "field_window_route_demo"; const CONNECTOR_INSTANCE_ID = "cin_field_window_route_demo"; +const SOURCE_ID = "https://sources.example/field-window-route-demo"; const STREAM = "emails"; const MANIFEST = { connector_id: CONNECTOR_ID, display_name: "Field Window Route Demo", + manifest_uri: "https://implementations.example/connectors/field-window-route-demo", protocol_version: "0.1.0", + source_declaration: { + declaration_version: "field-window-route-demo-source-v1", + display: { name: "Field Window Route Demo" }, + protocol_version: "0.1.0", + publisher: { id: "https://publishers.example/pdpp-test" }, + source: { id: SOURCE_ID, kind: "connector" }, + streams: [ + { + consent_time_field: "created_at", + cursor_field: "created_at", + name: STREAM, + primary_key: ["id"], + schema: { + properties: { + body: { type: "string" }, + created_at: { format: "date-time", type: "string" }, + id: { type: "string" }, + read_count: { type: "integer" }, + subject: { type: "string" }, + }, + required: ["id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "append_only", + }, + ], + }, streams: [ { consent_time_field: "created_at", @@ -63,7 +93,8 @@ const MANIFEST = { required: ["id"], type: "object", }, - selection: { fields: true }, + selection: { fields: true, resources: true }, + semantics: "append_only", }, ], version: "1.0.0", @@ -82,7 +113,7 @@ const SEED = [ // `startServer`'s inferred asServer/rsServer type comes from a framework // `.listen()` call whose TS overload resolves to an http2-shaped type, but at // runtime these are plain node:http/https servers (the framework never -// negotiates ALPN in this reference stack) — so `closeAllConnections` (added +// negotiates ALPN in this reference stack), so `closeAllConnections` (added // Node 18.2+) and the single-error-arg `close` callback genuinely exist and // are safe to declare here. Established pattern, see // connector-gap-severity.test.ts. @@ -162,7 +193,7 @@ interface GrantRequestParams { purpose_code: string; purpose_description: string; source?: { id: string; kind: string }; - streams: Array<{ fields?: string[]; name: string }>; + streams: Array<{ fields?: string[]; instance_ids?: string[]; name: string }>; } async function issueOwnerToken(asUrl: string, subjectId = "owner_local"): Promise<string> { @@ -214,17 +245,25 @@ async function startGrantRequest(asUrl: string, params: GrantRequestParams) { }); } -// biome-ignore lint/suspicious/useAwait: mock preserves the production Promise contract and rejection timing async function approveGrantRequest(asUrl: string, requestUri: string, subjectId = "owner_local") { - return fetchJson(`${asUrl}/consent/approve`, { + const review = await fetchJson(`${asUrl}/consent/review`, { body: JSON.stringify({ request_uri: requestUri, subject_id: subjectId }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + const reviewRevision = (review.body as Record<string, unknown>).approval_review_revision; + assert.equal(typeof reviewRevision, "string", "consent review must return approval_review_revision"); + return fetchJson(`${asUrl}/consent/approve`, { + body: JSON.stringify({ approval_review_revision: reviewRevision, request_uri: requestUri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); } async function approveGrant(asUrl: string, subjectId: string, params: GrantRequestParams): Promise<ApprovedGrant> { - const { body: initiateBody } = await startGrantRequest(asUrl, params); + const { body: initiateBody, status: initiateStatus } = await startGrantRequest(asUrl, params); + assert.equal(initiateStatus, 201, JSON.stringify(initiateBody)); assert.ok(initiateBody, "expected a PAR initiate response body"); const initiate = initiateBody as GrantRequestInitiateResponse; const { body: approvedBody } = await approveGrantRequest(asUrl, initiate.request_uri, subjectId); @@ -418,8 +457,8 @@ test("field-window route enforces client grant field projections", async () => { client_id: "longview", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "field window grant test", - source: { id: CONNECTOR_ID, kind: "connector" }, - streams: [{ fields: ["id", "created_at", "body"], name: STREAM }], + source: { id: SOURCE_ID, kind: "connector" }, + streams: [{ fields: ["id", "created_at", "body"], instance_ids: [CONNECTOR_INSTANCE_ID], name: STREAM }], }); assert.ok(approved.token, `expected issued grant token, got ${JSON.stringify(approved)}`); const auth = { headers: { Authorization: `Bearer ${approved.token}` } }; @@ -517,7 +556,7 @@ test("field-window route reports a non-text field as 422", async () => { await seedStream(rsUrl, ownerToken, CONNECTOR_ID, STREAM, SEED); const auth = { headers: { Authorization: `Bearer ${ownerToken}` } }; - // `read_count` is an integer field — well-formed request, but it cannot be + // `read_count` is an integer field: well-formed request, but it cannot be // served as a readable text window. const res = await fetchJson(fieldWindowUrl(rsUrl, STREAM, "e1", { field: "read_count" }), auth); assert.equal(res.status, 422, "non-text field is a 422"); diff --git a/reference-implementation/test/rs-records-detail-operation.test.ts b/reference-implementation/test/rs-records-detail-operation.test.ts index 1ffa5eecd..52fc37ceb 100644 --- a/reference-implementation/test/rs-records-detail-operation.test.ts +++ b/reference-implementation/test/rs-records-detail-operation.test.ts @@ -1,6 +1,7 @@ const TOP_LEVEL_REGEX_1 = /'missing'/; const TOP_LEVEL_REGEX_2 = /'pay_statements'/; const TOP_LEVEL_REGEX_3 = /definitely_not_a_field/; +const TOP_LEVEL_REGEX_4 = /Stream 'gone' not in grant/; // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 @@ -278,6 +279,29 @@ test("rs.records.get does not overwrite the grant for client actors", async () = }); }); +test("rs.records.get rejects client streams that are absent from the grant before fetching records", async () => { + let fetched = false; + await assert.rejects( + () => + executeRecordDetail( + { actor: clientActor, recordId: "rec_1", streamName: "gone" }, + makeDeps({ + getRecord: () => { + fetched = true; + return Promise.resolve({ id: "rec_1", object: "record" }); + }, + }) + ), + (err) => { + assert.ok(err instanceof RecordDetailVisibilityError); + assert.equal(err.code, "grant_stream_not_allowed"); + assert.match(err.message, TOP_LEVEL_REGEX_4); + return true; + } + ); + assert.equal(fetched, false); +}); + test("rs.records.get awaits async dependency promises", async () => { let resolved = false; const result = await executeRecordDetail( diff --git a/reference-implementation/test/rs-records-ingest-hosted-rejection-coordinator.test.ts b/reference-implementation/test/rs-records-ingest-hosted-rejection-coordinator.test.ts index 47a1e5165..1da1aa2ec 100644 --- a/reference-implementation/test/rs-records-ingest-hosted-rejection-coordinator.test.ts +++ b/reference-implementation/test/rs-records-ingest-hosted-rejection-coordinator.test.ts @@ -125,6 +125,7 @@ function manifest(connectorId: string) { connector_id: connectorId, connector_key: connectorId, display_name: "Hosted Rejection Coordinator Test", + manifest_uri: `https://registry.pdpp.dev/connectors/${connectorId}`, protocol_version: "0.1.0", streams: [ { diff --git a/reference-implementation/test/rs-records-list-operation.test.ts b/reference-implementation/test/rs-records-list-operation.test.ts index 08f8a9cb8..495d7d5ed 100644 --- a/reference-implementation/test/rs-records-list-operation.test.ts +++ b/reference-implementation/test/rs-records-list-operation.test.ts @@ -14,9 +14,10 @@ * - `disclosure.served`-shaped data is populated from the result counts; * - owner manifest visibility raises `not_found`; * - view/fields mutual exclusion raises `invalid_request`; - * - view → fields resolution sets `requestParams.fields` and clears + * - client grants reject absent streams with `grant_stream_not_allowed`; + * - owner view → fields resolution sets `requestParams.fields` and clears * `requestParams.view`; - * - a view referencing ungranted fields raises `field_not_granted`; + * - client views are rejected without consulting current definitions; * - `decorateRecord` is applied to every returned record; * - `validateRequestFields` is called with the resolved manifest stream. * @@ -35,6 +36,9 @@ import { RecordsListVisibilityError, } from "../operations/rs-records-list/index.ts"; +const TOP_LEVEL_REGEX_1 = /Stream 'gone' not in grant/; +const TOP_LEVEL_REGEX_2 = /must use explicit fields/; + const ownerActor: RecordsListActor = { kind: "owner", subject_id: "subj_1" }; const clientActor: RecordsListActor = { client_id: "client_x", @@ -117,22 +121,27 @@ test("rs.records.list throws not_found for owner when the manifest does not incl ); }); -test("rs.records.list does not 404 on missing manifest stream for client actors", async () => { - // Client actors rely on the underlying `queryRecords` capability for - // grant-shape rejection (the previous native route delegated to - // `queryRecords` for that branch). The operation must not 404. +test("rs.records.list rejects client streams that are absent from the grant before querying records", async () => { let called = false; - await executeRecordsList( - { actor: clientActor, requestParams: {}, streamName: "gone" }, - makeDeps({ - getManifest: () => ({ streams: [] }), - queryRecords: () => { - called = true; - return Promise.resolve({ data: [], has_more: false, object: "list" }); - }, - }) + await assert.rejects( + () => + executeRecordsList( + { actor: clientActor, requestParams: {}, streamName: "gone" }, + makeDeps({ + queryRecords: () => { + called = true; + return Promise.resolve({ data: [], has_more: false, object: "list" }); + }, + }) + ), + (err) => { + assert.ok(err instanceof RecordsListVisibilityError); + assert.equal(err.code, "grant_stream_not_allowed"); + assert.match(err.message, TOP_LEVEL_REGEX_1); + return true; + } ); - assert.equal(called, true); + assert.equal(called, false); }); test("rs.records.list rejects when both view and fields are present", async () => { @@ -179,7 +188,8 @@ test("rs.records.list resolves a view by id, sets fields, and removes view from assert.equal("view" in observedParams, false); }); -test("rs.records.list raises field_not_granted when the view names ungranted fields", async () => { +test("rs.records.list rejects client views without consulting their current definition", async () => { + let queried = false; await assert.rejects( () => executeRecordsList( @@ -193,14 +203,29 @@ test("rs.records.list raises field_not_granted when the view names ungranted fie getGrant: () => ({ streams: [{ fields: ["net_pay_minor", "employer"], name: "pay_statements" }], }), + getManifest: () => + Promise.resolve({ + streams: [ + { + name: "pay_statements", + views: [{ fields: ["newly_added_secret"], id: "unauthorized" }], + }, + ], + }), + queryRecords: () => { + queried = true; + return Promise.resolve({ data: [], has_more: false, object: "list" }); + }, }) ), (err) => { assert.ok(err instanceof RecordsListVisibilityError); - assert.equal(err.code, "field_not_granted"); + assert.equal(err.code, "invalid_request"); + assert.match(err.message, TOP_LEVEL_REGEX_2); return true; } ); + assert.equal(queried, false); }); test("rs.records.list raises invalid_request when the view id is unknown", async () => { diff --git a/reference-implementation/test/rs-schema-compact-view.test.ts b/reference-implementation/test/rs-schema-compact-view.test.ts index 19381d1fc..6f828857e 100644 --- a/reference-implementation/test/rs-schema-compact-view.test.ts +++ b/reference-implementation/test/rs-schema-compact-view.test.ts @@ -152,6 +152,7 @@ function makeLargeManifest({ streamCount = 6, fieldsPerStream = 30 } = {}) { capabilities: { human_interaction: [] }, connector_id: CONNECTOR_ID, display_name: "Compact Schema Fixture Connector", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, protocol_version: "0.1.0", streams: Array.from({ length: streamCount }, (_, s) => { const properties: ManifestProperties = { @@ -176,7 +177,8 @@ function makeLargeManifest({ streamCount = 6, fieldsPerStream = 30 } = {}) { primary_key: ["id"], query: { range_filters: rangeFilters }, schema: { properties, required: ["id", "received_at"], type: "object" }, - selection: { fields: { mode: "explicit" } }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }; }), version: "1.0.0", @@ -614,10 +616,12 @@ test("/v1/schema?detail=full rejects ambiguous stream detail before dumping mult assert.equal(ambiguous.body.error?.code, "ambiguous_schema_detail"); assert.equal(ambiguous.body.error?.retry_with, "connection_id"); assert.ok(ambiguous.body.error?.available_connections, "expected available_connections on the error"); - assert.deepEqual(ambiguous.body.error.available_connections.map((entry) => entry.connection_id).sort(), [ - "cin_detail_a", - "cin_detail_b", - ]); + assert.deepEqual( + ambiguous.body.error.available_connections + .map((entry) => entry.connection_id) + .sort((a, b) => (a ?? "").localeCompare(b ?? "")), + ["cin_detail_a", "cin_detail_b"] + ); const unscoped = await fetchJson(schemaUrl(rsUrl, { detail: "full" }), { headers: { Authorization: `Bearer ${ownerToken}` }, diff --git a/reference-implementation/test/rs-search-lexical-operation.test.ts b/reference-implementation/test/rs-search-lexical-operation.test.ts index 4d7fb9014..f8b85ea37 100644 --- a/reference-implementation/test/rs-search-lexical-operation.test.ts +++ b/reference-implementation/test/rs-search-lexical-operation.test.ts @@ -356,6 +356,31 @@ test("cursor round-trip slices the snapshot and rejects malformed/expired cursor assert.notEqual(item2.record_key, item1.record_key); }); +test("cursor replay rejects a changed query or narrowed grant authority", async () => { + const deps = makeDeps(); + const page1 = await executeSearchLexical({ actor: clientActor, query: { limit: "1", q: "foo" } }, deps); + const cursor = page1.envelope.next_cursor; + assert.ok(cursor); + await assert.rejects( + () => executeSearchLexical({ actor: clientActor, query: { cursor, q: "bar" } }, deps), + (err) => err instanceof SearchLexicalRequestError && err.code === "invalid_cursor" + ); + const narrowedActor: SearchLexicalActor = { + ...clientActor, + grant: { + ...clientGrant, + streams: [ + { instance_ids: ["cin_acme"], name: "pay_statements", resources: ["rec_2"] }, + { instance_ids: ["cin_acme"], name: "time_entries" }, + ], + }, + }; + await assert.rejects( + () => executeSearchLexical({ actor: narrowedActor, query: { cursor, q: "foo" } }, deps), + (err) => err instanceof SearchLexicalRequestError && err.code === "invalid_cursor" + ); +}); + test("malformed cursor raises invalid_cursor", async () => { const deps = makeDeps(); await assert.rejects( diff --git a/reference-implementation/test/rs-search-semantic-fan-in.test.ts b/reference-implementation/test/rs-search-semantic-fan-in.test.ts index 263a893eb..e8ed8cfd8 100644 --- a/reference-implementation/test/rs-search-semantic-fan-in.test.ts +++ b/reference-implementation/test/rs-search-semantic-fan-in.test.ts @@ -285,3 +285,26 @@ test("client-mode semantic fan-in: iterates every grant-authorized binding", asy ["B1", "A1"] ); }); + +test("client-mode semantic fan-in: skipped warning preserves the storage source identity", async () => { + const grant = { source: { id: "https://sources.example/gmail", kind: "connector" }, streams: [{ name: "messages" }] }; + const clientActor: SearchSemanticActor = { client_id: "c", grant, grant_id: "g", kind: "client", subject_id: "subj" }; + const deps = makeOwnerDepsWithBindings([], { cin_gmail_A: { _emptyPlan: true } }); + deps.resolveClientBindings = () => [ + { + connectorInstanceId: "cin_gmail_A", + manifest: { + storage_binding: { connector_id: "gmail", connector_instance_id: "cin_gmail_A" }, + streams: [{ name: "messages" }], + }, + }, + ]; + + const out = await executeSearchSemantic({ actor: clientActor, query: { q: "foo" } }, deps); + const skipped = (out.envelope.meta?.warnings || []).find((w) => w.code === "source_skipped_not_applicable") as + | SearchSemanticWarning + | undefined; + assert.ok(skipped); + assert.equal(skipped.detail?.connection_id, "cin_gmail_A"); + assert.equal(skipped.detail?.source, "gmail"); +}); diff --git a/reference-implementation/test/rs-search-semantic-operation.test.ts b/reference-implementation/test/rs-search-semantic-operation.test.ts index ccf0d6d3f..07764e558 100644 --- a/reference-implementation/test/rs-search-semantic-operation.test.ts +++ b/reference-implementation/test/rs-search-semantic-operation.test.ts @@ -480,6 +480,35 @@ test("cursor round-trip slices the snapshot and produces sem1.-prefixed cursors" assert.notEqual(item2.record_key, item1.record_key); }); +test("semantic cursor replay rejects a changed query or narrowed grant authority", async () => { + const deps = makeDeps(); + const page1 = await executeSearchSemantic({ actor: clientActor, query: { limit: "1", q: "foo" } }, deps); + const cursor = page1.envelope.next_cursor; + assert.ok(cursor); + await assert.rejects( + () => executeSearchSemantic({ actor: clientActor, query: { cursor, q: "bar" } }, deps), + (err) => err instanceof SearchSemanticRequestError && err.code === "invalid_cursor" + ); + const narrowedActor: SearchSemanticActor = { + ...clientActor, + grant: { + ...clientGrant, + streams: [ + { + instance_ids: ["cin_acme"], + name: "pay_statements", + time_constraint: { field: "issued_at", since: "2026-01-01T00:00:00Z" }, + }, + { instance_ids: ["cin_acme"], name: "time_entries" }, + ], + }, + }; + await assert.rejects( + () => executeSearchSemantic({ actor: narrowedActor, query: { cursor, q: "foo" } }, deps), + (err) => err instanceof SearchSemanticRequestError && err.code === "invalid_cursor" + ); +}); + test("cursor without sem1. prefix raises invalid_cursor", async () => { const deps = makeDeps(); // A base64url-encoded JSON cursor *without* the sem1. prefix — i.e. the diff --git a/reference-implementation/test/rs-streams-field-declared-type.test.ts b/reference-implementation/test/rs-streams-field-declared-type.test.ts index 46ce128b7..78dd1504f 100644 --- a/reference-implementation/test/rs-streams-field-declared-type.test.ts +++ b/reference-implementation/test/rs-streams-field-declared-type.test.ts @@ -30,8 +30,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { startServer } from "../server/index.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; +import { makeDefaultAccountConnectorInstanceId } from "../server/stores/connector-instance-store.ts"; -const CONNECTOR_ID = "streams-field-declared-type"; +const CONNECTOR_KEY = "codex"; +const CONNECTOR_ID = `https://registry.pdpp.dev/connectors/${CONNECTOR_KEY}`; const STREAM = "transactions"; const TEST_DCR_INITIAL_ACCESS_TOKEN = "pdpp-reference-test-initial-access-token"; @@ -101,7 +104,8 @@ const baseManifest = { required: ["id", "amount_cents", "posted_at"], type: "object", }, - selection: { fields: { mode: "explicit" } }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -214,9 +218,17 @@ async function approveGrant(asUrl: string, subjectId: string, params: ApproveGra if (!initiate?.request_uri) { throw new Error(`startGrantRequest returned no request_uri: ${JSON.stringify(initiate)}`); } - const { body: approvedBody } = await fetchJson(`${asUrl}/consent/approve`, { + const review = await fetchJson(`${asUrl}/consent/review`, { body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: subjectId }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + const reviewRevision = (review.body as Record<string, unknown>).approval_review_revision; + assert.equal(typeof reviewRevision, "string", "consent review must return approval_review_revision"); + const { body: approvedBody } = await fetchJson(`${asUrl}/consent/approve`, { + body: JSON.stringify({ approval_review_revision: reviewRevision, request_uri: initiate.request_uri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); return approvedBody as ApprovedGrant; @@ -239,6 +251,20 @@ async function withHttpHarness(fn: (urls: { asUrl: string; rsUrl: string }) => P method: "POST", }); assert.equal(registerResp.status, 201, "register connector"); + const connectorInstanceId = makeDefaultAccountConnectorInstanceId("owner_local", CONNECTOR_KEY); + const now = new Date().toISOString(); + await createRequestConnectorInstanceStore().upsert({ + connectorId: CONNECTOR_KEY, + connectorInstanceId, + createdAt: now, + displayName: "Declared-Type Test Account", + ownerSubjectId: "owner_local", + sourceBinding: { fixture: "rs-streams-field-declared-type" }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); await fn({ asUrl, rsUrl }); } finally { await closeServer(server); @@ -268,7 +294,7 @@ interface FieldCapability { async function readStreamMetadata(rsUrl: string, token: string): Promise<Record<string, FieldCapability>> { const { status, body } = await fetchJson( - `${rsUrl}/v1/streams/${encodeURIComponent(STREAM)}?connector_id=${encodeURIComponent(CONNECTOR_ID)}`, + `${rsUrl}/v1/streams/${encodeURIComponent(STREAM)}?connector_id=${encodeURIComponent(CONNECTOR_KEY)}`, { headers: { Authorization: `Bearer ${token}` } } ); assert.equal(status, 200, `GET /v1/streams/${STREAM} should be 200`); @@ -395,19 +421,15 @@ test("declared type does not alter grant usability under a client token", async const fc = await readStreamMetadata(rsUrl, approved.token); - // amount_cents is granted: type present AND granted true. + // The grant projection carries only field names. Current presentation + // types are not authorization evidence and are therefore omitted. assert.ok(fc.amount_cents, "amount_cents field_capabilities present"); - assert.equal(fc.amount_cents.type, "currency"); + assert.equal(fc.amount_cents.type, undefined); assert.equal(fc.amount_cents.granted, true); // merchant declares a type but is NOT in the grant: the declared type does // not rescue grant usability — granted is false, just like undeclared // ungranted fields. - if (fc.merchant) { - assert.equal(fc.merchant.type, "string"); - assert.equal(fc.merchant.granted, false); - assert.ok(fc.merchant.exact_filter, "merchant exact_filter present"); - assert.equal(fc.merchant.exact_filter.usable, false); - } + assert.equal(fc.merchant, undefined); }); }); diff --git a/reference-implementation/test/run-interaction-stream-routes.test.ts b/reference-implementation/test/run-interaction-stream-routes.test.ts index effbf132d..1d89cc379 100644 --- a/reference-implementation/test/run-interaction-stream-routes.test.ts +++ b/reference-implementation/test/run-interaction-stream-routes.test.ts @@ -4170,13 +4170,23 @@ test("restore failure cancels the run instead of resuming against the mutated pr assert.equal(rejected.status, 500); const timeline = await waitForRunTerminal(asUrl, started.run_id); assert.equal( - timeline.data.some((event) => event.event_type === "run.completed"), - false + timeline.data.some( + (event) => event.event_type === "run.stream_session_resolved" && event.data?.restore_failed === true + ), + true + ); + assert.equal( + timeline.data.some((event) => event.event_type === "run.cancel_requested"), + true ); assert.equal( timeline.data.some((event) => event.event_type === "run.cancelled"), true ); + assert.equal( + timeline.data.some((event) => event.event_type === "run.completed"), + false + ); abort.abort(); } ); @@ -4371,13 +4381,23 @@ test("restore failure after bearer expiry cancels instead of resuming on present assert.equal(rejected.status, 500); const timeline = await waitForRunTerminal(asUrl, started.run_id); assert.equal( - timeline.data.some((event) => event.event_type === "run.completed"), - false + timeline.data.some( + (event) => event.event_type === "run.stream_session_resolved" && event.data?.restore_failed === true + ), + true + ); + assert.equal( + timeline.data.some((event) => event.event_type === "run.cancel_requested"), + true ); assert.equal( timeline.data.some((event) => event.event_type === "run.cancelled"), true ); + assert.equal( + timeline.data.some((event) => event.event_type === "run.completed"), + false + ); abort.abort(); } ); diff --git a/reference-implementation/test/run-tests-reporter-determinism.test.ts b/reference-implementation/test/run-tests-reporter-determinism.test.ts index 84f849181..cfd8506cf 100644 --- a/reference-implementation/test/run-tests-reporter-determinism.test.ts +++ b/reference-implementation/test/run-tests-reporter-determinism.test.ts @@ -40,10 +40,10 @@ const BUDGET_EXHAUSTED_PATTERN = /\[REDACTED scan budget exhausted\]/; * * The fix (reference-implementation/scripts/run-tests.ts) stops forwarding * --test-force-exit to child `node --test` processes and instead bounds a - * genuinely hung file with a runner-level SIGKILL watchdog that only fires - * after the child fails to exit on its own within PDPP_TEST_FILE_TIMEOUT_MS. - * A normal run drains its reporter completely and exits before the watchdog - * ever fires. + * genuinely hung file with a runner-level SIGKILL watchdog. The watchdog + * fires after PDPP_TEST_FILE_TIMEOUT_MS without output or at the separate + * PDPP_TEST_FILE_HARD_TIMEOUT_MS absolute deadline. A normal run drains its + * reporter completely and exits before either deadline fires. * * This test spawns the real reporter against the file where the race was * observed live (compact-record-history.test.js has both a large pure-helper diff --git a/reference-implementation/test/runtime-cancel-run.test.ts b/reference-implementation/test/runtime-cancel-run.test.ts index 5f21a7f9d..146238ce7 100644 --- a/reference-implementation/test/runtime-cancel-run.test.ts +++ b/reference-implementation/test/runtime-cancel-run.test.ts @@ -104,12 +104,20 @@ function startMockRs() { // no-op SIGTERM handler so the runtime must escalate to SIGKILL (the forced // path). Records and state are emitted at module top level after START so the // runtime flushes the record and stages the cursor before the test aborts. -function writeStub({ ignoreSigterm }: { ignoreSigterm: boolean }): { stubPath: string; tmpDir: string } { +function writeStub({ doneOnSigterm, ignoreSigterm }: { doneOnSigterm?: boolean; ignoreSigterm: boolean }): { + stubPath: string; + tmpDir: string; +} { const tmpDir = mkdtempSync(join(tmpdir(), "pdpp-runtime-cancel-")); const stubPath = join(tmpDir, "stub.mjs"); - const sigtermLine = ignoreSigterm - ? "process.on('SIGTERM', () => { /* deliberately ignore: force SIGKILL escalation */ });" - : "// default SIGTERM disposition: terminate the process"; + let sigtermLine = "// default SIGTERM disposition: terminate the process"; + if (ignoreSigterm) { + sigtermLine = "process.on('SIGTERM', () => { /* deliberately ignore: force SIGKILL escalation */ });"; + } + if (doneOnSigterm) { + sigtermLine = + "process.on('SIGTERM', () => { emit({ type: 'DONE', status: 'succeeded', records_emitted: 1 }); setTimeout(() => process.exit(0), 10); });"; + } writeFileSync( stubPath, ` @@ -197,14 +205,17 @@ function assertRunEventsUseDefaultAccountBinding(runId: string): SpineEventRow[] return events; } -async function runCancelScenario(t: TestContext, { ignoreSigterm, runId }: { ignoreSigterm: boolean; runId: string }) { +async function runCancelScenario( + t: TestContext, + { doneOnSigterm, ignoreSigterm, runId }: { doneOnSigterm?: boolean; ignoreSigterm: boolean; runId: string } +) { freshDb(t); const { server, requests, ingested } = startMockRs(); await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); assert.ok(address && typeof address === "object", "expected an AddressInfo from an ephemeral listen(0)"); const rsUrl = `http://127.0.0.1:${address.port}`; - const { stubPath, tmpDir } = writeStub({ ignoreSigterm }); + const { stubPath, tmpDir } = writeStub({ ...(doneOnSigterm === undefined ? {} : { doneOnSigterm }), ignoreSigterm }); const controller = new AbortController(); // Abort as soon as the RS has received the flushed record: the run is past @@ -295,6 +306,9 @@ test("runtime owner-cancel: connector that ignores SIGTERM is force-terminated runId, }); + if (outcomeError) { + throw outcomeError; + } const result = asStructuredOutcome(outcome ?? outcomeError); assert.equal(result.status, "cancelled", "force-cancelled run resolves status=cancelled"); assert.equal( @@ -314,3 +328,27 @@ test("runtime owner-cancel: connector that ignores SIGTERM is force-terminated assert.ok(events.includes("run.cancelled"), "a terminal run.cancelled is recorded"); assert.ok(!events.includes("run.failed"), "a force-cancelled run does NOT terminal as run.failed"); }); + +test("runtime owner-cancel: a raced successful DONE still returns the cancelled result", async (t) => { + const runId = "run_cancel_raced_done"; + const { outcome, outcomeError, requests } = await runCancelScenario(t, { + doneOnSigterm: true, + ignoreSigterm: false, + runId, + }); + + if (outcomeError) { + throw outcomeError; + } + const result = asStructuredOutcome(outcome ?? outcomeError); + assert.equal(result.status, "cancelled"); + assert.equal(result.terminal_reason, "owner_cancelled"); + assert.equal( + requests.some((request) => request.method === "PUT" && request.pathname.startsWith("/v1/state/")), + false, + "a raced successful DONE cannot commit staged state after cancellation" + ); + const events = assertRunEventsUseDefaultAccountBinding(runId).map((event) => event.event_type); + assert.ok(events.includes("run.cancelled")); + assert.ok(!events.includes("run.completed")); +}); diff --git a/reference-implementation/test/runtime-ingest-manifest-drift.test.ts b/reference-implementation/test/runtime-ingest-manifest-drift.test.ts index 22682b517..9a28e1d62 100644 --- a/reference-implementation/test/runtime-ingest-manifest-drift.test.ts +++ b/reference-implementation/test/runtime-ingest-manifest-drift.test.ts @@ -22,6 +22,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { isTransientManifestDriftIngestFailure, loadSyncState, runConnector } from "../runtime/index.ts"; +import { getConnectorManifest } from "../server/auth.ts"; import { startServer } from "../server/index.ts"; import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; import { admitOwnerRunConnection } from "../server/stores/connector-instance-store.ts"; @@ -102,8 +103,17 @@ function rsRegisteredManifest(connectorId: string) { return { connector_id: connectorId, display_name: "Drift Test Connector", + manifest_uri: `https://sources.example/${connectorId}`, protocol_version: "0.1.0", - streams: [{ name: "items", primary_key: ["id"], schema: streamSchema(), semantics: "append_only" }], + streams: [ + { + name: "items", + primary_key: ["id"], + schema: streamSchema(), + selection: { fields: true, resources: true }, + semantics: "append_only", + }, + ], version: "1.0.0", }; } @@ -114,12 +124,39 @@ function runtimeManifest(connectorId: string) { return { ...rsRegisteredManifest(connectorId), streams: [ - { name: "items", primary_key: ["id"], schema: streamSchema(), semantics: "append_only" }, - { name: "drift_stream", primary_key: ["id"], schema: streamSchema(), semantics: "append_only" }, + { + name: "items", + primary_key: ["id"], + schema: streamSchema(), + selection: { fields: true, resources: true }, + semantics: "append_only", + }, + { + name: "drift_stream", + primary_key: ["id"], + schema: streamSchema(), + selection: { fields: true, resources: true }, + semantics: "append_only", + }, ], }; } +type RuntimeManifest = Parameters<typeof runConnector>[0]["manifest"]; + +function runtimeCompatibleManifest(manifest: { + streams: ReadonlyArray<{ name: string; selection?: unknown; [key: string]: unknown }>; + [key: string]: unknown; +}): RuntimeManifest { + return { + ...manifest, + streams: manifest.streams.map((stream) => { + const { selection: _selection, ...withoutSelection } = stream; + return withoutSelection; + }), + }; +} + function createTestConnector(messages: Record<string, unknown>[]) { const tmpDir = mkdtempSync(join(tmpdir(), "pdpp-drift-connector-")); const connectorPath = join(tmpDir, "connector.mjs"); @@ -144,11 +181,12 @@ rl.on('line', (line) => { } async function registerManifest(asUrl: string, manifest: Record<string, unknown>): Promise<void> { - await fetchJson(`${asUrl}/connectors`, { + const result = await fetchJson(`${asUrl}/connectors`, { body: JSON.stringify(manifest), headers: { "Content-Type": "application/json" }, method: "POST", }); + assert.equal(result.status, 201, `drift fixture registration: ${JSON.stringify(result.body)}`); } interface DeviceAuthorizationBody { @@ -194,6 +232,7 @@ test("transient manifest drift: scope-stream ingest not_found degrades to a per- const asUrl = `http://localhost:${asPort}`; const connectorId = "drift-test"; await registerManifest(asUrl, rsRegisteredManifest(connectorId)); // RS lacks drift_stream + assert.ok(await getConnectorManifest(connectorId), "drift fixture registration"); const ownerToken = await issueOwnerToken(asUrl); // items ingests fine (200); drift_stream 404s (RS manifest lacks it). @@ -211,7 +250,7 @@ test("transient manifest drift: scope-stream ingest not_found degrades to a per- collectionMode: "full_refresh", connectorId, connectorPath, - manifest: runtimeManifest(connectorId), // runtime scope INCLUDES drift_stream + manifest: runtimeCompatibleManifest(runtimeManifest(connectorId)), // runtime scope INCLUDES drift_stream onInteraction: async () => ({}), ownerToken, persistState: true, @@ -299,7 +338,7 @@ test("transient manifest drift: scope-stream ingest not_found degrades to a per- collectionMode: "full_refresh", connectorId, connectorPath, - manifest: rsRegisteredManifest(connectorId), // runtime scope EXCLUDES not_in_scope + manifest: runtimeCompatibleManifest(rsRegisteredManifest(connectorId)), // runtime scope EXCLUDES not_in_scope onInteraction: async () => ({}), ownerToken, persistState: true, diff --git a/reference-implementation/test/runtime-ingest-systemic-failure-journey.test.ts b/reference-implementation/test/runtime-ingest-systemic-failure-journey.test.ts index 33e87c9ee..a2c8fabef 100644 --- a/reference-implementation/test/runtime-ingest-systemic-failure-journey.test.ts +++ b/reference-implementation/test/runtime-ingest-systemic-failure-journey.test.ts @@ -45,7 +45,9 @@ async function fetchJson(url: string, init?: RequestInit): Promise<{ body: unkno function manifest(connectorId: string) { return { connector_id: connectorId, + connector_key: connectorId, display_name: "Runtime systemic failure journey", + manifest_uri: `https://registry.pdpp.dev/connectors/${connectorId}`, protocol_version: "0.1.0", streams: [ { @@ -56,18 +58,25 @@ function manifest(connectorId: string) { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", }; } +function runtimeManifest(connectorId: string): Parameters<typeof runConnector>[0]["manifest"] { + return manifest(connectorId) as Parameters<typeof runConnector>[0]["manifest"]; +} + async function registerManifest(asUrl: string, connectorManifest: Record<string, unknown>): Promise<void> { - await fetchJson(`${asUrl}/connectors`, { + const response = await fetchJson(`${asUrl}/connectors`, { body: JSON.stringify(connectorManifest), headers: { "Content-Type": "application/json" }, method: "POST", }); + assert.equal(response.status, 201, JSON.stringify(response.body)); } async function issueOwnerToken(asUrl: string): Promise<string> { @@ -151,7 +160,7 @@ test("runtime journey: non-ok systemic ingest fails the run and does not commit connectorId, connectorInstanceId: admitted.connectorInstanceId, connectorPath: connector.connectorPath, - manifest: manifest(connectorId), + manifest: runtimeManifest(connectorId), onInteraction: async () => ({}), ownerToken, persistState: true, diff --git a/reference-implementation/test/runtime-record-rejection-multistream-isolation.test.ts b/reference-implementation/test/runtime-record-rejection-multistream-isolation.test.ts index 9e2d28495..d425943dc 100644 --- a/reference-implementation/test/runtime-record-rejection-multistream-isolation.test.ts +++ b/reference-implementation/test/runtime-record-rejection-multistream-isolation.test.ts @@ -112,16 +112,34 @@ function streamSchema() { function manifest(connectorId: string) { return { connector_id: connectorId, + connector_key: connectorId, display_name: "Runtime Record Rejection Multistream Test Connector", + manifest_uri: `https://registry.pdpp.dev/connectors/${connectorId}`, protocol_version: "0.1.0", streams: [ - { name: "stream_a", primary_key: ["id"], schema: streamSchema(), semantics: "append_only" }, - { name: "stream_b", primary_key: ["id"], schema: streamSchema(), semantics: "append_only" }, + { + name: "stream_a", + primary_key: ["id"], + schema: streamSchema(), + selection: { fields: true, resources: true }, + semantics: "append_only", + }, + { + name: "stream_b", + primary_key: ["id"], + schema: streamSchema(), + selection: { fields: true, resources: true }, + semantics: "append_only", + }, ], version: "1.0.0", }; } +function runtimeManifest(connectorId: string): Parameters<typeof runConnector>[0]["manifest"] { + return manifest(connectorId) as Parameters<typeof runConnector>[0]["manifest"]; +} + function createTestConnector(messages: readonly Record<string, unknown>[]) { const tmpDir = mkdtempSync(join(tmpdir(), "pdpp-runtime-record-rejection-multistream-connector-")); const connectorPath = join(tmpDir, "connector.mjs"); @@ -263,7 +281,7 @@ test("runtime/server SQLite isolates durable record rejections and cursor commit connectorId, connectorInstanceId, connectorPath, - manifest: manifest(connectorId), + manifest: runtimeManifest(connectorId), onInteraction: async () => ({}), onProgress: (message) => progress.push(message), ownerToken, diff --git a/reference-implementation/test/runtime-record-rejection-system-journey.test.ts b/reference-implementation/test/runtime-record-rejection-system-journey.test.ts index 1798ec330..acce7800b 100644 --- a/reference-implementation/test/runtime-record-rejection-system-journey.test.ts +++ b/reference-implementation/test/runtime-record-rejection-system-journey.test.ts @@ -141,13 +141,27 @@ function streamSchema() { function manifest(connectorId: string) { return { connector_id: connectorId, + connector_key: connectorId, display_name: "Runtime Record Rejection Journey Test Connector", + manifest_uri: `https://registry.pdpp.dev/connectors/${connectorId}`, protocol_version: "0.1.0", - streams: [{ name: "items", primary_key: ["id"], schema: streamSchema(), semantics: "append_only" }], + streams: [ + { + name: "items", + primary_key: ["id"], + schema: streamSchema(), + selection: { fields: true, resources: true }, + semantics: "append_only", + }, + ], version: "1.0.0", }; } +function runtimeManifest(connectorId: string): Parameters<typeof runConnector>[0]["manifest"] { + return manifest(connectorId) as Parameters<typeof runConnector>[0]["manifest"]; +} + function createTestConnector(messages: readonly Record<string, unknown>[]) { const tmpDir = mkdtempSync(join(tmpdir(), "pdpp-runtime-record-rejection-connector-")); const connectorPath = join(tmpDir, "connector.mjs"); @@ -590,7 +604,7 @@ test("runtime/server SQLite journey durably receipts invalid identity rejections connectorId, connectorInstanceId, connectorPath, - manifest: manifest(connectorId), + manifest: runtimeManifest(connectorId), onInteraction: async () => ({}), onProgress: (message) => progress.push(message), ownerToken, @@ -1019,7 +1033,7 @@ test("runtime/server SQLite response loss replays the same durable rejection rec connectorId, connectorInstanceId, connectorPath: firstAttempt.connectorPath, - manifest: manifest(connectorId), + manifest: runtimeManifest(connectorId), onInteraction: async () => ({}), onProgress: (message) => firstProgress.push(message), ownerToken, @@ -1093,7 +1107,7 @@ test("runtime/server SQLite response loss replays the same durable rejection rec connectorId, connectorInstanceId, connectorPath: secondAttempt.connectorPath, - manifest: manifest(connectorId), + manifest: runtimeManifest(connectorId), onInteraction: async () => ({}), onProgress: (message) => secondProgress.push(message), ownerToken, @@ -1284,7 +1298,7 @@ test("runtime cancellation after a committed rejection response preserves the re connectorId, connectorInstanceId, connectorPath, - manifest: manifest(connectorId), + manifest: runtimeManifest(connectorId), onInteraction: async () => ({}), onProgress: (message) => progress.push(message), ownerToken, diff --git a/reference-implementation/test/scheduler-static-secret-injection.test.ts b/reference-implementation/test/scheduler-static-secret-injection.test.ts index 8e3667c99..11cae7ee2 100644 --- a/reference-implementation/test/scheduler-static-secret-injection.test.ts +++ b/reference-implementation/test/scheduler-static-secret-injection.test.ts @@ -266,6 +266,8 @@ function minimalNestedAuthManifest(connectorKey: string): Record<string, unknown name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "0.1.0", @@ -1034,6 +1036,8 @@ test("connector stderr progress and retained diagnostics redact secret and proxy name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "0.1.0", diff --git a/reference-implementation/test/scheduler-store-semantic-surface.test.ts b/reference-implementation/test/scheduler-store-semantic-surface.test.ts index c89d41683..68b9c7b2a 100644 --- a/reference-implementation/test/scheduler-store-semantic-surface.test.ts +++ b/reference-implementation/test/scheduler-store-semantic-surface.test.ts @@ -58,6 +58,7 @@ const SEMANTIC_MANIFEST = { name: "stream_x", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "mutable_state", }, ], diff --git a/reference-implementation/test/scheduler.test.ts b/reference-implementation/test/scheduler.test.ts index 3c6c4cd6e..ff10f437a 100644 --- a/reference-implementation/test/scheduler.test.ts +++ b/reference-implementation/test/scheduler.test.ts @@ -1123,6 +1123,7 @@ test("scheduler preserves failure reasons and checkpoint summaries from failed r const manifest = { connector_id: "scheduler-failure-test", display_name: "Scheduler Failure Test Connector", + manifest_uri: "https://registry.pdpp.org/connectors/scheduler-failure-test", protocol_version: "0.1.0", streams: [ { @@ -1136,6 +1137,7 @@ test("scheduler preserves failure reasons and checkpoint summaries from failed r required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1253,6 +1255,7 @@ test("scheduler preserves partial checkpoint commit summaries from state persist const manifest = { connector_id: "scheduler-partial-checkpoint-test", display_name: "Scheduler Partial Checkpoint Test Connector", + manifest_uri: "https://registry.pdpp.org/connectors/scheduler-partial-checkpoint-test", protocol_version: "0.1.0", streams: [ { @@ -1266,6 +1269,7 @@ test("scheduler preserves partial checkpoint commit summaries from state persist required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, { @@ -1279,6 +1283,7 @@ test("scheduler preserves partial checkpoint commit summaries from state persist required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1455,6 +1460,7 @@ test("scheduler preserves terminal counter mismatch failures from runConnector r const manifest = { connector_id: "scheduler-terminal-counter-mismatch-test", display_name: "Scheduler Terminal Counter Mismatch Test Connector", + manifest_uri: "https://registry.pdpp.org/connectors/scheduler-terminal-counter-mismatch-test", protocol_version: "0.1.0", streams: [ { @@ -1468,6 +1474,7 @@ test("scheduler preserves terminal counter mismatch failures from runConnector r required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1600,6 +1607,7 @@ test("scheduler preserves connector-declared terminal error details from failed const manifest = { connector_id: "scheduler-terminal-error-test", display_name: "Scheduler Terminal Error Test Connector", + manifest_uri: "https://registry.pdpp.org/connectors/scheduler-terminal-error-test", protocol_version: "0.1.0", streams: [ { @@ -1613,6 +1621,7 @@ test("scheduler preserves connector-declared terminal error details from failed required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1733,6 +1742,7 @@ test("scheduler preserves known gaps from partial connector runs", async () => { const manifest = { connector_id: "scheduler-known-gap-test", display_name: "Scheduler Known Gap Test Connector", + manifest_uri: "https://registry.pdpp.org/connectors/scheduler-known-gap-test", protocol_version: "0.1.0", streams: [ { @@ -1745,6 +1755,7 @@ test("scheduler preserves known gaps from partial connector runs", async () => { required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1840,6 +1851,7 @@ test("scheduler preserves connector-declared terminal error details from cancell const manifest = { connector_id: "scheduler-cancelled-terminal-error-test", display_name: "Scheduler Cancelled Terminal Error Test Connector", + manifest_uri: "https://registry.pdpp.org/connectors/scheduler-cancelled-terminal-error-test", protocol_version: "0.1.0", streams: [ { @@ -1853,6 +1865,7 @@ test("scheduler preserves connector-declared terminal error details from cancell required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -1973,6 +1986,7 @@ test("scheduler does not retry deterministic connector protocol violations", asy const manifest = { connector_id: "scheduler-no-retry-protocol-violation", display_name: "Scheduler No Retry Protocol Violation Connector", + manifest_uri: "https://registry.pdpp.org/connectors/scheduler-no-retry-protocol-violation", protocol_version: "0.1.0", streams: [ { @@ -1985,6 +1999,7 @@ test("scheduler does not retry deterministic connector protocol violations", asy required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -2076,6 +2091,7 @@ test("scheduler retries connector-declared retryable failures and records the su const manifest = { connector_id: "scheduler-retryable-terminal-error", display_name: "Scheduler Retryable Terminal Error Connector", + manifest_uri: "https://registry.pdpp.org/connectors/scheduler-retryable-terminal-error", protocol_version: "0.1.0", streams: [ { @@ -2089,6 +2105,7 @@ test("scheduler retries connector-declared retryable failures and records the su required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -2205,6 +2222,7 @@ test("scheduler does not retry connector-declared non-retryable failures", async const manifest = { connector_id: "scheduler-nonretryable-terminal-error", display_name: "Scheduler Nonretryable Terminal Error Connector", + manifest_uri: "https://registry.pdpp.org/connectors/scheduler-nonretryable-terminal-error", protocol_version: "0.1.0", streams: [ { @@ -2218,6 +2236,7 @@ test("scheduler does not retry connector-declared non-retryable failures", async required: ["id"], type: "object", }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], @@ -4370,12 +4389,14 @@ test("scheduler marks connector as needs-human when automatic run triggers inter const manifest = { connector_id: "scheduler-interaction-test", display_name: "Interaction Test Connector", + manifest_uri: "https://registry.pdpp.org/connectors/scheduler-interaction-test", protocol_version: "0.1.0", streams: [ { name: "items", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, + selection: { fields: true, resources: true }, semantics: "append_only", }, ], diff --git a/reference-implementation/test/schema-capabilities-projection.test.ts b/reference-implementation/test/schema-capabilities-projection.test.ts index d50421e18..47f8138ac 100644 --- a/reference-implementation/test/schema-capabilities-projection.test.ts +++ b/reference-implementation/test/schema-capabilities-projection.test.ts @@ -82,7 +82,7 @@ test("buildFieldCapabilities: object-typed field is NOT exact_filterable", () => ); }); -test("buildFieldCapabilities: ungranted field is declared-but-not-usable with field_not_granted reason", () => { +test("buildFieldCapabilities: grant metadata omits typed filter capability flags", () => { const caps = buildFieldCapabilities( streamWith({ secret: { type: "string" }, status: { type: "string" } }), { fields: ["status"] } // grant only exposes `status` @@ -91,10 +91,8 @@ test("buildFieldCapabilities: ungranted field is declared-but-not-usable with fi const secret = field(caps, "secret"); assert.equal(status.granted, true); assert.equal(secret.granted, false); - const secretExactFilter = secret.exact_filter as Record<string, unknown>; - assert.equal(secretExactFilter.declared, true, "schema still declares it filterable"); - assert.equal(secretExactFilter.usable, false, "but not usable without grant"); - assert.equal(secretExactFilter.reason, "field_not_granted"); + assert.equal(secret.exact_filter, undefined); + assert.equal(secret.range_filter, undefined); }); test("buildFieldCapabilities: range_filter surfaces declared operators from manifest", () => { diff --git a/reference-implementation/test/schema-capabilities.test.ts b/reference-implementation/test/schema-capabilities.test.ts index ce3dad237..cc6fe9ea5 100644 --- a/reference-implementation/test/schema-capabilities.test.ts +++ b/reference-implementation/test/schema-capabilities.test.ts @@ -129,8 +129,8 @@ test("buildFieldCapabilities applies field_not_granted when the grant omits a fi const priority = field(caps, "priority"); assert.equal(subject.granted, true); assert.equal(sentAt.granted, false); - assert.equal((sentAt.exact_filter as Record<string, unknown>).usable, false); - assert.equal((sentAt.range_filter as Record<string, unknown>).reason, "field_not_granted"); + assert.equal(sentAt.exact_filter, undefined); + assert.equal(sentAt.range_filter, undefined); const priorityAggregation = priority.aggregation as Record<string, Record<string, unknown> | undefined>; assert.equal(priorityAggregation.sum?.usable, false); assert.equal(priorityAggregation.sum?.reason, "field_not_granted"); diff --git a/reference-implementation/test/schema-granted-connections.test.ts b/reference-implementation/test/schema-granted-connections.test.ts index 16a91c46d..0a52af216 100644 --- a/reference-implementation/test/schema-granted-connections.test.ts +++ b/reference-implementation/test/schema-granted-connections.test.ts @@ -14,9 +14,8 @@ * Covers: * * - multi-connection owner scope returns every active connection; - * - grant constrained to one `connection_id` returns only that connection; - * - grant without `connection_id` constraint preserves fan-in across - * active connections; + * - grant `instance_ids` authorize only those connections; + * - explicit multi-instance grants preserve intentional fan-in; * - owner-renamed `display_name` propagates to the next schema response; * - storage placeholder labels (`legacy`, `default_account`, connector_id * defaults) are omitted from the wire (no leakage of non-granted / @@ -36,6 +35,7 @@ import { startServer } from "../server/index.ts"; import { OWNER_AUTH_DEFAULT_SUBJECT_ID } from "../server/owner-auth.ts"; import { ingestRecord } from "../server/records.ts"; import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; +import { TEST_INTROSPECTION_SERVER_OPTS } from "./helpers/introspection-test-credentials.ts"; interface GrantedConnection { connection_id: string; @@ -43,9 +43,9 @@ interface GrantedConnection { } type ListGrantedConnectionsForStream = (args: { + authorizedInstanceIds?: string[] | null; ownerSubjectId: string | null; connectorId: string | null; - grantStreamConnectionId?: string | null; }) => Promise<GrantedConnection[]>; const listGrantedConnectionsForStream = listGrantedConnectionsForStreamUntyped as ListGrantedConnectionsForStream; @@ -56,36 +56,51 @@ function at<T>(items: T[], index: number): T { return item; } -const CONNECTOR_ID = "schema-granted-connections"; +const CONNECTOR_ID = "spotify"; +const SOURCE_ID = "https://registry.pdpp.dev/connectors/spotify"; const STREAM = "messages"; const INSTANCE_A = "cin_schema_account_a"; const INSTANCE_B = "cin_schema_account_b"; +const baseStreams = [ + { + consent_time_field: "received_at", + cursor_field: "received_at", + name: STREAM, + primary_key: ["id"], + query: {}, + schema: { + $defs: { + subject: { type: "string" }, + }, + properties: { + id: { type: "string" }, + received_at: { format: "date-time", type: "string" }, + subject: { $ref: "#/$defs/subject" }, + }, + required: ["id", "subject", "received_at"], + type: "object", + }, + selection: { fields: true, resources: false }, + semantics: "mutable_state", + }, +]; + const baseManifest = { capabilities: { human_interaction: [] }, connector_id: CONNECTOR_ID, display_name: "Schema Granted Connections Test Connector", protocol_version: "0.1.0", - streams: [ - { - consent_time_field: "received_at", - cursor_field: "received_at", - name: STREAM, - primary_key: ["id"], - query: {}, - schema: { - properties: { - id: { type: "string" }, - received_at: { format: "date-time", type: "string" }, - subject: { type: "string" }, - }, - required: ["id", "subject", "received_at"], - type: "object", - }, - selection: { fields: { mode: "explicit" } }, - }, - ], + source_declaration: { + declaration_version: "schema-granted-connections.v1", + display: { name: "Schema Granted Connections Test Connector" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation/tests" }, + source: { id: SOURCE_ID, kind: "connector" }, + streams: baseStreams, + }, + streams: baseStreams, version: "1.0.0", }; @@ -102,7 +117,12 @@ function record(id: string, receivedAt: string) { }; } -async function seedInstance(instanceId: string, displayName: string, sourceBindingKey: string): Promise<void> { +async function seedInstance( + instanceId: string, + displayName: string, + sourceBindingKey: string, + ownerSubjectId = OWNER_AUTH_DEFAULT_SUBJECT_ID +): Promise<void> { const store = createSqliteConnectorInstanceStore(); const now = new Date().toISOString(); await store.upsert({ @@ -110,7 +130,7 @@ async function seedInstance(instanceId: string, displayName: string, sourceBindi connectorInstanceId: instanceId, createdAt: now, displayName, - ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, + ownerSubjectId, sourceBinding: { account: sourceBindingKey }, sourceBindingKey, sourceKind: "account", @@ -186,11 +206,11 @@ test("owner scope enumerates every active connection with meaningful display_nam // ─── Grant constrained to one connection ────────────────────────────────── -test("grant constrained to one connection_id returns only that connection", async () => { +test("grant instance_ids constrained to one connection returns only that connection", async () => { await withDualConnectionDb(async () => { const granted = await listGrantedConnectionsForStream({ + authorizedInstanceIds: [INSTANCE_B], connectorId: CONNECTOR_ID, - grantStreamConnectionId: INSTANCE_B, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); assert.equal(granted.length, 1); @@ -201,11 +221,11 @@ test("grant constrained to one connection_id returns only that connection", asyn // ─── Grant without connection_id constraint preserves fan-in ───────────── -test("grant without connection_id constraint returns every active connection", async () => { +test("grant with both instance_ids returns every authorized active connection", async () => { await withDualConnectionDb(async () => { const granted = await listGrantedConnectionsForStream({ + authorizedInstanceIds: [INSTANCE_A, INSTANCE_B], connectorId: CONNECTOR_ID, - grantStreamConnectionId: null, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); // biome-ignore lint/suspicious/useArraySortCompare: Fixture values use the runtime default sort semantics under test. @@ -448,9 +468,20 @@ async function approveGrant( if (!initiate.request_uri) { throw new Error(`startGrantRequest returned no request_uri: ${JSON.stringify(initiate)}`); } - const { body: approved } = await fetchJson(`${asUrl}/consent/approve`, { + const review = await fetchJson(`${asUrl}/consent/review`, { body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: subjectId }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + const reviewRevision = (review.body as Record<string, unknown>).approval_review_revision; + assert.equal(typeof reviewRevision, "string", "consent review must return approval_review_revision"); + const { body: approved } = await fetchJson(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: reviewRevision, + request_uri: initiate.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); return asRecord(approved); @@ -466,6 +497,7 @@ async function withHttpHarness( dynamicClientRegistrationInitialAccessTokens: [TEST_DCR_INITIAL_ACCESS_TOKEN], quiet: true, rsPort: 0, + ...TEST_INTROSPECTION_SERVER_OPTS, }); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -486,8 +518,18 @@ async function withHttpHarness( } interface WireStream { + consent_time_field?: string; + field_capabilities?: Record<string, unknown>; + freshness?: { captured_at?: string; status?: string }; granted_connections?: GrantedConnection[]; + instance_ids?: string[]; name: string; + relationships?: Record<string, unknown>[]; + resources?: string[]; + schema?: Record<string, unknown>; + selection?: unknown; + time_constraint?: Record<string, unknown>; + views?: unknown[]; } interface WireConnector { @@ -535,7 +577,7 @@ test("GET /v1/schema emits granted_connections for multi-connection owner scope" ); }); -test("GET /v1/schema honors grant.streams[].connection_id constraint", async () => { +test("GET /v1/schema exposes only grant.streams[].instance_ids", async () => { await withHttpHarness( async ({ asUrl, rsUrl }) => { const approved = await approveGrant(asUrl, "owner_local", { @@ -543,11 +585,11 @@ test("GET /v1/schema honors grant.streams[].connection_id constraint", async () client_id: "longview", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "granted_connections scope test", - source: { id: CONNECTOR_ID, kind: "connector" }, + source: { id: SOURCE_ID, kind: "connector" }, streams: [ { - connection_id: INSTANCE_B, fields: ["id", "subject", "received_at"], + instance_ids: [INSTANCE_B], name: STREAM, }, ], @@ -562,7 +604,7 @@ test("GET /v1/schema honors grant.streams[].connection_id constraint", async () const connector = at(connectors, 0); const stream = findStream(connector, STREAM); const grantedConnections = stream.granted_connections ?? []; - assert.equal(grantedConnections.length, 1); + assert.equal(grantedConnections.length, 1, JSON.stringify(body)); assert.equal(at(grantedConnections, 0).connection_id, INSTANCE_B); assert.equal(at(grantedConnections, 0).display_name, "Account B"); // The non-granted connection MUST NOT appear anywhere in the body. @@ -584,7 +626,7 @@ test("GET /v1/schema honors grant.streams[].connection_id constraint", async () ); }); -test("GET /v1/schema returns every active connection when grant omits connection_id", async () => { +test("GET /v1/schema returns every explicitly authorized instance", async () => { await withHttpHarness( async ({ asUrl, rsUrl }) => { const approved = await approveGrant(asUrl, "owner_local", { @@ -592,8 +634,14 @@ test("GET /v1/schema returns every active connection when grant omits connection client_id: "longview", purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "granted_connections fan-in test", - source: { id: CONNECTOR_ID, kind: "connector" }, - streams: [{ fields: ["id", "subject", "received_at"], name: STREAM }], + source: { id: SOURCE_ID, kind: "connector" }, + streams: [ + { + fields: ["id", "subject", "received_at"], + instance_ids: [INSTANCE_A, INSTANCE_B], + name: STREAM, + }, + ], }); assert.ok(approved.token, "expected client token"); const { status, body: bodyRaw } = await fetchJson(`${rsUrl}/v1/schema`, { @@ -604,7 +652,7 @@ test("GET /v1/schema returns every active connection when grant omits connection const connectors = body.connectors as WireConnector[]; const stream = findStream(at(connectors, 0), STREAM); const ids = (stream.granted_connections ?? []).map((g) => g.connection_id).sort(); - assert.deepEqual(ids, [INSTANCE_A, INSTANCE_B]); + assert.deepEqual(ids, [INSTANCE_A, INSTANCE_B], JSON.stringify(body)); }, { seed: async () => { @@ -616,3 +664,239 @@ test("GET /v1/schema returns every active connection when grant omits connection } ); }); + +test("client metadata uses the approving subject's granted instance and never owner_local", async () => { + const hostedSubject = "hosted_schema_owner"; + const hostedInstance = "cin_schema_hosted"; + const ownerLocalTimestamp = "2026-05-25T12:09:00.000Z"; + const hostedTimestamp = "2026-05-25T12:03:00.000Z"; + await withHttpHarness( + async ({ asUrl, rsUrl }) => { + const approved = await approveGrant(asUrl, hostedSubject, { + access_mode: "continuous", + client_id: "longview", + purpose_code: "https://pdpp.dev/purpose/analytics", + purpose_description: "hosted subject metadata isolation", + source: { id: SOURCE_ID, kind: "connector" }, + streams: [ + { + fields: ["id", "received_at"], + instance_ids: [hostedInstance], + name: STREAM, + }, + ], + }); + assert.ok(approved.token, JSON.stringify(approved)); + const headers = { Authorization: `Bearer ${String(approved.token)}` }; + + const schemaResponse = await fetchJson(`${rsUrl}/v1/schema`, { headers }); + assert.equal(schemaResponse.status, 200, JSON.stringify(schemaResponse.body)); + const schemaBody = asRecord(schemaResponse.body); + const schemaStream = findStream(at(schemaBody.connectors as WireConnector[], 0), STREAM); + assert.equal(schemaStream.freshness?.captured_at, hostedTimestamp); + assert.deepEqual(schemaStream.instance_ids, [hostedInstance]); + + const connectorsResponse = await fetchJson(`${rsUrl}/v1/connectors`, { headers }); + assert.equal(connectorsResponse.status, 200, JSON.stringify(connectorsResponse.body)); + const connectorsBody = asRecord(connectorsResponse.body); + const connector = at(connectorsBody.data as Array<{ streams: Record<string, unknown>[] }>, 0); + const connectorStream = at(connector.streams, 0); + assert.equal(connectorStream.record_count, 1); + assert.equal((connectorStream.freshness as { captured_at?: string }).captured_at, hostedTimestamp); + + const streamResponse = await fetchJson(`${rsUrl}/v1/streams/${STREAM}`, { headers }); + assert.equal(streamResponse.status, 200, JSON.stringify(streamResponse.body)); + const streamBody = asRecord(streamResponse.body); + assert.equal((streamBody.freshness as { captured_at?: string }).captured_at, hostedTimestamp); + + const serialized = JSON.stringify([schemaBody, connectorsBody, streamBody]); + assert.equal( + serialized.includes(ownerLocalTimestamp), + false, + "owner_local freshness leaked into hosted metadata" + ); + assert.equal(serialized.includes(INSTANCE_A), false, "owner_local instance leaked into hosted metadata"); + }, + { + seed: async () => { + await seedInstance(INSTANCE_A, "Local account", "local@example.com"); + await seedInstance(hostedInstance, "Hosted account", "hosted@example.com", hostedSubject); + await ingestRecord(target(INSTANCE_A), record("local-1", ownerLocalTimestamp)); + await ingestRecord(target(hostedInstance), record("hosted-1", hostedTimestamp)); + }, + } + ); +}); + +test("client schema and stream metadata remain a closed resolved-grant projection after manifest changes", async () => { + await withHttpHarness( + async ({ asUrl, rsUrl }) => { + const approved = await approveGrant(asUrl, "owner_local", { + access_mode: "continuous", + client_id: "longview", + purpose_code: "https://pdpp.dev/purpose/analytics", + purpose_description: "frozen client metadata projection", + source: { id: SOURCE_ID, kind: "connector" }, + streams: [ + { + fields: ["id", "received_at"], + instance_ids: [INSTANCE_A], + name: STREAM, + time_range: { since: "2026-01-01T00:00:00Z" }, + }, + ], + }); + assert.ok(approved.token, JSON.stringify(approved)); + + const changedManifest = structuredClone(baseManifest) as Record<string, unknown>; + const [changedStream] = changedManifest.streams as Record<string, unknown>[]; + assert.ok(changedStream); + const changedSchema = changedStream.schema as Record<string, unknown>; + changedSchema.properties = { + ...(changedSchema.properties as Record<string, unknown>), + id: { type: "integer" }, + secret: { type: "string" }, + }; + changedSchema.$defs = { + subject: { properties: { changed: { type: "boolean" } }, type: "object" }, + }; + changedSchema.required = ["id", "received_at", "secret"]; + changedSchema.additionalProperties = true; + changedSchema.patternProperties = { "^secret_": { type: "string" } }; + changedSchema.allOf = [{ required: ["secret"] }]; + changedStream.consent_time_field = "secret"; + changedStream.selection = { fields: false, resources: false }; + changedStream.views = [{ fields: ["secret"], id: "secret", label: "Secret" }]; + const updateResponse = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(changedManifest), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(updateResponse.status, 201); + + const clientHeaders = { Authorization: `Bearer ${String(approved.token)}` }; + await Promise.all( + [`${rsUrl}/v1/schema`, `${rsUrl}/v1/streams/${STREAM}`].map(async (url) => { + const response = await fetchJson(url, { headers: clientHeaders }); + assert.equal(response.status, 200, JSON.stringify(response.body)); + const body = asRecord(response.body); + const stream = url.endsWith("/schema") + ? findStream(at(body.connectors as WireConnector[], 0), STREAM) + : (body as unknown as WireStream); + const schema = stream.schema as Record<string, unknown>; + assert.deepEqual(Object.keys(schema.properties as Record<string, unknown>).sort(), [ + "id", + "received_at", + "subject", + ]); + assert.deepEqual((schema.properties as Record<string, unknown>).id, {}); + assert.equal("$defs" in schema, false); + assert.equal("required" in schema, false); + assert.equal(schema.additionalProperties, false); + assert.equal("patternProperties" in schema, false); + assert.equal("allOf" in schema, false); + assert.equal(stream.consent_time_field, "received_at"); + assert.deepEqual(stream.instance_ids, [INSTANCE_A]); + assert.deepEqual(stream.time_constraint, { + field: "received_at", + since: "2026-01-01T00:00:00Z", + }); + assert.equal(JSON.stringify(stream).includes("secret"), false); + }) + ); + + const ownerToken = await issueOwnerToken(asUrl); + const ownerResponse = await fetchJson(`${rsUrl}/v1/schema?connector_id=${encodeURIComponent(CONNECTOR_ID)}`, { + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + assert.equal(ownerResponse.status, 200, JSON.stringify(ownerResponse.body)); + const ownerBody = asRecord(ownerResponse.body); + const ownerStream = findStream(at(ownerBody.connectors as WireConnector[], 0), STREAM); + assert.equal(ownerStream.consent_time_field, "secret"); + assert.equal( + "secret" in ((ownerStream.schema as Record<string, unknown>).properties as Record<string, unknown>), + true + ); + assert.deepEqual(ownerStream.selection, { fields: false, resources: false }); + assert.equal(ownerStream.views?.length, 1); + }, + { + seed: async () => { + await seedInstance(INSTANCE_A, "Account A", "a@example.com"); + await ingestRecord(target(INSTANCE_A), record("a-1", "2026-05-25T12:00:00.000Z")); + }, + } + ); +}); + +test("client relationship metadata checks has_many foreign keys against the related stream grant", async () => { + await withHttpHarness( + async ({ asUrl, rsUrl }) => { + const manifest = structuredClone(baseManifest) as Record<string, unknown>; + const [parent] = manifest.streams as Record<string, unknown>[]; + assert.ok(parent); + parent.relationships = [ + { + cardinality: "has_many", + foreign_key: "message_id", + name: "replies", + stream: "replies", + }, + ]; + (manifest.streams as Record<string, unknown>[]).push({ + name: "replies", + primary_key: ["id"], + schema: { + properties: { id: { type: "string" }, message_id: { type: "string" } }, + required: ["id"], + type: "object", + }, + selection: { fields: true, resources: false }, + semantics: "mutable_state", + }); + const update = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(manifest), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(update.status, 201); + + const approve = (childFields: string[], purposeDescription: string) => + approveGrant(asUrl, "owner_local", { + access_mode: "continuous", + client_id: "longview", + purpose_code: "https://pdpp.dev/purpose/analytics", + purpose_description: purposeDescription, + source: { id: SOURCE_ID, kind: "connector" }, + streams: [ + { fields: ["id"], instance_ids: [INSTANCE_A], name: STREAM }, + { fields: childFields, instance_ids: [INSTANCE_A], name: "replies" }, + ], + }); + + const withForeignKey = await approve(["id", "message_id"], "relationship with child key"); + const withoutForeignKey = await approve(["id"], "relationship without child key"); + await Promise.all( + ( + [ + [withForeignKey, 0], + [withoutForeignKey, 0], + ] as const + ).map(async ([approval, expectedCount]) => { + const response = await fetchJson(`${rsUrl}/v1/schema`, { + headers: { Authorization: `Bearer ${String(approval.token)}` }, + }); + assert.equal(response.status, 200, JSON.stringify(response.body)); + const body = asRecord(response.body); + const stream = findStream(at(body.connectors as WireConnector[], 0), STREAM); + assert.equal(stream.relationships?.length, expectedCount, JSON.stringify(stream)); + }) + ); + }, + { + seed: async () => { + await seedInstance(INSTANCE_A, "Account A", "a@example.com"); + }, + } + ); +}); diff --git a/reference-implementation/test/seam-spike/artifacts/.gitignore b/reference-implementation/test/seam-spike/artifacts/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/reference-implementation/test/seam-spike/artifacts/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/gnap/approved.json b/reference-implementation/test/seam-spike/fixtures/pr89/gnap/approved.json new file mode 100644 index 000000000..0aa460d2f --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/gnap/approved.json @@ -0,0 +1,25 @@ +{ + "access": { + "type": "pdpp-approved-authorization", + "source_id": "https://sources.example/records/spotify", + "access_mode": "single_use", + "streams": [ + { + "name": "top_artists", + "instance_ids": ["account-a"], + "fields": ["id", "name"], + "time_constraint": { + "field": "played_at", + "since": "2026-01-01T00:00:00Z", + "until": "2026-04-01T00:00:00Z" + }, + "resources": ["artist:42"] + }, + { + "name": "recently_played", + "instance_ids": ["account-a"], + "fields": ["track_id", "played_at"] + } + ] + } +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/gnap/partial.json b/reference-implementation/test/seam-spike/fixtures/pr89/gnap/partial.json new file mode 100644 index 000000000..f479c9783 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/gnap/partial.json @@ -0,0 +1,20 @@ +{ + "access": { + "type": "pdpp-approved-authorization", + "source_id": "https://sources.example/records/spotify", + "access_mode": "single_use", + "streams": [ + { + "name": "top_artists", + "instance_ids": ["account-a"], + "fields": ["id", "name"], + "time_constraint": { + "field": "played_at", + "since": "2026-01-01T00:00:00Z", + "until": "2026-04-01T00:00:00Z" + }, + "resources": ["artist:42"] + } + ] + } +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/gnap/unknown-mandatory.json b/reference-implementation/test/seam-spike/fixtures/pr89/gnap/unknown-mandatory.json new file mode 100644 index 000000000..ebaf972e9 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/gnap/unknown-mandatory.json @@ -0,0 +1,16 @@ +{ + "access": { + "type": "pdpp-approved-authorization", + "source_id": "https://sources.example/records/spotify", + "access_mode": "single_use", + "streams": [ + { + "name": "top_artists", + "instance_ids": ["account-a"], + "fields": ["id", "name"] + } + ], + "must_understand": ["unimplemented_control"], + "unimplemented_control": true + } +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/grant-v01.json b/reference-implementation/test/seam-spike/fixtures/pr89/grant-v01.json new file mode 100644 index 000000000..b6968810f --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/grant-v01.json @@ -0,0 +1,44 @@ +{ + "access_mode": "continuous", + "client": { + "client_id": "pr89-seam-client" + }, + "expires_at": null, + "grant_id": "grt_pr89", + "issued_at": "2026-08-11T11:55:00Z", + "purpose_code": "https://pdpp.dev/purpose/personal_ai_assistant", + "purpose_description": "Build a personal listening summary", + "retention": { + "max_duration": "P30D", + "on_expiry": "delete" + }, + "source": { + "id": "https://sources.example/records/spotify", + "kind": "connector" + }, + "source_declaration": { + "version": "spotify-pr89-v1" + }, + "streams": [ + { + "fields": ["id", "name", "observed_at"], + "instance_ids": ["account-a"], + "name": "top_artists", + "resources": ["artist:42"], + "time_constraint": { + "field": "observed_at", + "since": "2026-01-01T00:00:00Z", + "until": "2026-04-01T00:00:00Z" + } + }, + { + "fields": ["id", "played_at", "title"], + "instance_ids": ["account-b"], + "name": "recently_played" + } + ], + "subject": { + "id": "owner-local" + }, + "version": "0.1.0" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/client-mismatch.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/client-mismatch.json new file mode 100644 index 000000000..c91db36a3 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/client-mismatch.json @@ -0,0 +1,6 @@ +{ + "kind": "response", + "path": "pdpp.client_id", + "value": "wrong-client", + "expected": "context.identity_mismatch" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/expired.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/expired.json new file mode 100644 index 000000000..0f959817e --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/expired.json @@ -0,0 +1 @@ +{ "kind": "response", "path": "exp", "value": 0, "expected": "context.expired" } diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/field-mismatch.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/field-mismatch.json new file mode 100644 index 000000000..7e827b080 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/field-mismatch.json @@ -0,0 +1,6 @@ +{ + "kind": "request", + "query": "fields=private_note", + "expected": "unknown_field", + "status": 400 +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/grant-mismatch.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/grant-mismatch.json new file mode 100644 index 000000000..f629c2381 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/grant-mismatch.json @@ -0,0 +1,6 @@ +{ + "kind": "response", + "path": "pdpp.grant_id", + "value": "wrong-grant", + "expected": "context.grant_mismatch" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/inactive.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/inactive.json new file mode 100644 index 000000000..294397952 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/inactive.json @@ -0,0 +1,6 @@ +{ + "kind": "response", + "path": "active", + "value": false, + "expected": "context.active_false" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/instance-mismatch.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/instance-mismatch.json new file mode 100644 index 000000000..eceb0303d --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/instance-mismatch.json @@ -0,0 +1,5 @@ +{ + "kind": "request", + "query": "connection_id=account-b", + "expected": "context.instance_mismatch" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/rights-missing.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/rights-missing.json new file mode 100644 index 000000000..6a49439f1 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/rights-missing.json @@ -0,0 +1,6 @@ +{ + "kind": "response", + "path": "authorization_details", + "delete": true, + "expected": "context.rights_missing" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/source-mismatch.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/source-mismatch.json new file mode 100644 index 000000000..12da1b612 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/source-mismatch.json @@ -0,0 +1,6 @@ +{ + "kind": "response", + "path": "pdpp.source.id", + "value": "https://wrong.example/source", + "expected": "context.source_mismatch" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/stale-cache.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/stale-cache.json new file mode 100644 index 000000000..4efbd33e2 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/stale-cache.json @@ -0,0 +1,6 @@ +{ + "kind": "response", + "path": "cache_expires_at", + "value": 0, + "expected": "context.cache_stale" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/subject-mismatch.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/subject-mismatch.json new file mode 100644 index 000000000..0c6d6520d --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/subject-mismatch.json @@ -0,0 +1,6 @@ +{ + "kind": "response", + "path": "pdpp.subject_id", + "value": "wrong-subject", + "expected": "context.identity_mismatch" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-audience.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-audience.json new file mode 100644 index 000000000..cd118ae33 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-audience.json @@ -0,0 +1,6 @@ +{ + "kind": "response", + "path": "aud", + "value": "https://wrong.example", + "expected": "context.audience_mismatch" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-context-kind.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-context-kind.json new file mode 100644 index 000000000..8e8a1fc7e --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-context-kind.json @@ -0,0 +1,6 @@ +{ + "kind": "response", + "path": "pdpp.context_kind", + "value": "unsupported", + "expected": "context.kind_mismatch" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-credentials.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-credentials.json new file mode 100644 index 000000000..b84715a2c --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-credentials.json @@ -0,0 +1 @@ +{ "kind": "credentials", "expected": "context.authentication_failed" } diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-issuer.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-issuer.json new file mode 100644 index 000000000..99656da48 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/mutations/wrong-issuer.json @@ -0,0 +1,6 @@ +{ + "kind": "response", + "path": "iss", + "value": "https://wrong.example", + "expected": "context.issuer_mismatch" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/introspection/valid.json b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/valid.json new file mode 100644 index 000000000..2417b3aff --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/introspection/valid.json @@ -0,0 +1 @@ +{ "kind": "response", "expected": "active" } diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/legacy-grant-v01.bytes b/reference-implementation/test/seam-spike/fixtures/pr89/legacy-grant-v01.bytes new file mode 100644 index 000000000..bc84162c0 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/legacy-grant-v01.bytes @@ -0,0 +1 @@ +{"access_mode":"continuous","client":{"client_id":"legacy_client","registration_mode":"pre_registered_public"},"expires_at":null,"grant_id":"grt_legacy","issued_at":"2026-08-11T12:00:00Z","manifest_version":"1.0.0","purpose_code":"https://pdpp.dev/purpose/personalization","purpose_description":"Captured pre-Source grant shape","source":{"id":"https://registry.pdpp.dev/connectors/spotify","kind":"connector"},"streams":[{"fields":["id","name"],"name":"top_artists"}],"subject":{"id":"owner_local"},"version":"0.1.0"} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/rar-approved.json b/reference-implementation/test/seam-spike/fixtures/pr89/rar-approved.json new file mode 100644 index 000000000..70d6e8493 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/rar-approved.json @@ -0,0 +1,32 @@ +{ + "access_mode": "continuous", + "purpose_code": "https://pdpp.dev/purpose/personal_ai_assistant", + "purpose_description": "Build a personal listening summary", + "retention": { + "max_duration": "P30D", + "on_expiry": "delete" + }, + "source": { + "id": "https://sources.example/records/spotify", + "kind": "connector" + }, + "streams": [ + { + "fields": ["id", "name", "observed_at"], + "instance_ids": ["account-a"], + "name": "top_artists", + "resources": ["artist:42"], + "time_constraint": { + "field": "observed_at", + "since": "2026-01-01T00:00:00Z", + "until": "2026-04-01T00:00:00Z" + } + }, + { + "fields": ["id", "played_at", "title"], + "instance_ids": ["account-b"], + "name": "recently_played" + } + ], + "type": "https://pdpp.dev/data-access" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/rar-request-invalid.json b/reference-implementation/test/seam-spike/fixtures/pr89/rar-request-invalid.json new file mode 100644 index 000000000..206157354 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/rar-request-invalid.json @@ -0,0 +1,15 @@ +{ + "access_mode": "continuous", + "purpose_code": "https://pdpp.dev/purpose/personal_ai_assistant", + "source": { + "id": "https://sources.example/records/spotify", + "kind": "connector" + }, + "streams": [ + { + "fields": [], + "name": "top_artists" + } + ], + "type": "https://pdpp.dev/data-access" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/rar-request.json b/reference-implementation/test/seam-spike/fixtures/pr89/rar-request.json new file mode 100644 index 000000000..a6d9ddcad --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/rar-request.json @@ -0,0 +1,33 @@ +{ + "access_mode": "continuous", + "purpose_code": "https://pdpp.dev/purpose/personal_ai_assistant", + "purpose_description": "Build a personal listening summary", + "retention": { + "max_duration": "P30D", + "on_expiry": "delete" + }, + "source": { + "id": "https://sources.example/records/spotify", + "kind": "connector" + }, + "streams": [ + { + "fields": ["id", "name", "observed_at"], + "instance_ids": ["account-a"], + "name": "top_artists", + "necessity": "required", + "resources": ["artist:42"], + "time_range": { + "since": "2026-01-01T00:00:00Z", + "until": "2026-04-01T00:00:00Z" + } + }, + { + "fields": ["id", "played_at", "title"], + "instance_ids": ["account-b"], + "name": "recently_played", + "necessity": "optional" + } + ], + "type": "https://pdpp.dev/data-access" +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/records.json b/reference-implementation/test/seam-spike/fixtures/pr89/records.json new file mode 100644 index 000000000..f43e55092 --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/records.json @@ -0,0 +1,32 @@ +{ + "allowed": { + "data": { + "id": "artist:42", + "name": "Allowed", + "observed_at": "2026-02-01T00:00:00Z" + }, + "emitted_at": "2026-02-01T00:00:00Z", + "key": "artist:42", + "stream": "top_artists" + }, + "outside_resource": { + "data": { + "id": "artist:99", + "name": "Outside resource", + "observed_at": "2026-02-01T00:00:00Z" + }, + "emitted_at": "2026-02-01T00:00:00Z", + "key": "artist:99", + "stream": "top_artists" + }, + "outside_time": { + "data": { + "id": "artist:42", + "name": "Outside time", + "observed_at": "2026-06-01T00:00:00Z" + }, + "emitted_at": "2026-06-01T00:00:00Z", + "key": "artist:42", + "stream": "top_artists" + } +} diff --git a/reference-implementation/test/seam-spike/fixtures/pr89/source.json b/reference-implementation/test/seam-spike/fixtures/pr89/source.json new file mode 100644 index 000000000..79d7d735a --- /dev/null +++ b/reference-implementation/test/seam-spike/fixtures/pr89/source.json @@ -0,0 +1,54 @@ +{ + "declaration_version": "spotify-pr89-v1", + "display": { + "name": "Spotify" + }, + "protocol_version": "0.1.0", + "publisher": { + "id": "https://publishers.example/spotify" + }, + "source": { + "id": "https://sources.example/records/spotify", + "kind": "connector" + }, + "streams": [ + { + "consent_time_field": "observed_at", + "name": "top_artists", + "primary_key": ["id"], + "schema": { + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "observed_at": { "format": "date-time", "type": "string" } + }, + "required": ["id", "observed_at"], + "type": "object" + }, + "selection": { + "fields": true, + "resources": true + }, + "semantics": "mutable_state" + }, + { + "consent_time_field": "played_at", + "name": "recently_played", + "primary_key": ["id"], + "schema": { + "properties": { + "id": { "type": "string" }, + "played_at": { "format": "date-time", "type": "string" }, + "title": { "type": "string" } + }, + "required": ["id", "played_at"], + "type": "object" + }, + "selection": { + "fields": true, + "resources": true + }, + "semantics": "append_only" + } + ] +} diff --git a/reference-implementation/test/seam-spike/pr89-case-1-source-contract.test.ts b/reference-implementation/test/seam-spike/pr89-case-1-source-contract.test.ts new file mode 100644 index 000000000..9bbad85bf --- /dev/null +++ b/reference-implementation/test/seam-spike/pr89-case-1-source-contract.test.ts @@ -0,0 +1,215 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { CoreSourceAuthorizationError } from "../../server/core-source-authorization.ts"; +import { + ApprovedAuthorizationError, + parseGrantedAuthorizationDetail, + parseResolvedGrantApprovedAuthorization, + requireApprovedAuthorizationNarrowing, +} from "../../server/source-approved-authorization.ts"; +import { writePr89CaseOutput } from "./pr89-case-output.ts"; + +function fixture(name: string): Record<string, unknown> { + return JSON.parse(readFileSync(new URL(`./fixtures/pr89/${name}`, import.meta.url), "utf8")) as Record< + string, + unknown + >; +} + +function clone<T>(value: T): T { + return structuredClone(value); +} + +function firstStream(value: Record<string, unknown>): Record<string, unknown> { + const streams = value.streams as Record<string, unknown>[]; + const [stream] = streams; + assert.ok(stream); + return stream; +} + +function assertAuthCode(code: ApprovedAuthorizationError["code"], mutate: (value: Record<string, unknown>) => void) { + const declaration = fixture("source.json"); + const value = fixture("rar-approved.json"); + mutate(value); + assert.throws( + () => parseGrantedAuthorizationDetail(value, declaration), + (error: unknown) => error instanceof ApprovedAuthorizationError && error.code === code + ); +} + +test("persisted grant and approved RAR project to equal neutral authorization", async (t) => { + const declaration = fixture("source.json"); + const grant = fixture("grant-v01.json"); + const rar = fixture("rar-approved.json"); + const fromGrant = parseResolvedGrantApprovedAuthorization(grant, declaration); + const fromRar = parseGrantedAuthorizationDetail(rar, declaration).authorization; + + assert.deepEqual(fromGrant, fromRar); + assert.deepEqual( + fromGrant.streams.map((stream) => [stream.name, stream.instance_ids, stream.fields]), + [ + ["top_artists", ["account-a"], ["id", "name", "observed_at"]], + ["recently_played", ["account-b"], ["id", "played_at", "title"]], + ] + ); + + await t.test("provenance variants stay outside equality and mismatches fail before projection", () => { + const changedBindingFacts = clone(grant); + changedBindingFacts.client = { client_id: "different-client" }; + changedBindingFacts.subject = { id: "different-subject" }; + changedBindingFacts.grant_id = "different-grant"; + assert.deepEqual(parseResolvedGrantApprovedAuthorization(changedBindingFacts, declaration), fromGrant); + + const providerDeclaration = clone(declaration); + (providerDeclaration.source as Record<string, unknown>).kind = "provider_native"; + const providerGrant = clone(grant); + (providerGrant.source as Record<string, unknown>).kind = "provider_native"; + const providerRar = clone(rar); + (providerRar.source as Record<string, unknown>).kind = "provider_native"; + assert.deepEqual(parseResolvedGrantApprovedAuthorization(providerGrant, providerDeclaration), fromGrant); + assert.deepEqual(parseGrantedAuthorizationDetail(providerRar, providerDeclaration).authorization, fromGrant); + + const mismatched = clone(rar); + (mismatched.source as Record<string, unknown>).kind = "provider_native"; + assert.throws( + () => parseGrantedAuthorizationDetail(mismatched, declaration), + (error: unknown) => + error instanceof CoreSourceAuthorizationError && error.code === "source.authorization_details_invalid" + ); + }); + + const invalidCases: [ApprovedAuthorizationError["code"], (value: Record<string, unknown>) => void][] = [ + [ + "auth.source_id_empty", + (value) => { + (value.source as Record<string, unknown>).id = ""; + }, + ], + [ + "auth.access_mode_invalid", + (value) => { + value.access_mode = "read"; + }, + ], + [ + "auth.streams_empty", + (value) => { + value.streams = []; + }, + ], + [ + "auth.stream_name_empty", + (value) => { + firstStream(value).name = ""; + }, + ], + [ + "auth.stream_name_duplicate", + (value) => { + const streams = value.streams as Record<string, unknown>[]; + const [, second] = streams; + assert.ok(second); + second.name = "top_artists"; + }, + ], + [ + "auth.instance_ids_empty", + (value) => { + firstStream(value).instance_ids = []; + }, + ], + [ + "auth.instance_id_empty", + (value) => { + firstStream(value).instance_ids = [""]; + }, + ], + [ + "auth.instance_id_duplicate", + (value) => { + firstStream(value).instance_ids = ["account-a", "account-a"]; + }, + ], + [ + "auth.fields_empty", + (value) => { + firstStream(value).fields = []; + }, + ], + [ + "auth.field_empty", + (value) => { + firstStream(value).fields = [""]; + }, + ], + [ + "auth.field_duplicate", + (value) => { + firstStream(value).fields = ["id", "id"]; + }, + ], + [ + "auth.time_constraint_invalid", + (value) => { + firstStream(value).time_constraint = { field: "observed_at" }; + }, + ], + [ + "auth.time_field_changed", + (value) => { + const constraint = firstStream(value).time_constraint as Record<string, unknown>; + constraint.field = "played_at"; + }, + ], + [ + "auth.resources_empty", + (value) => { + firstStream(value).resources = []; + }, + ], + [ + "auth.resource_duplicate", + (value) => { + firstStream(value).resources = ["artist:42", "artist:42"]; + }, + ], + [ + "auth.unknown_member", + (value) => { + firstStream(value).scope = "all"; + }, + ], + ]; + await t.test("invalid and widening mutations return stable authorization codes", () => { + for (const [code, mutate] of invalidCases) { + assertAuthCode(code, mutate); + } + + const widened = clone(fromRar); + const [widenedStream] = widened.streams; + assert.ok(widenedStream); + widenedStream.instance_ids.push("account-c"); + assert.throws( + () => requireApprovedAuthorizationNarrowing(widened, fromGrant), + (error: unknown) => error instanceof ApprovedAuthorizationError && error.code === "auth.widened" + ); + }); + + writePr89CaseOutput({ + case_id: "case-1", + observations: [ + "approved_authorization_equal", + "binding_fields_excluded", + "instance_and_field_rows_observed", + "invalid_mutations_rejected", + ], + oracle_code: "equal", + response_envelopes: [], + schema: "pdpp.pr89.case-output.v1", + }); +}); diff --git a/reference-implementation/test/seam-spike/pr89-case-2-partial-approval.test.ts b/reference-implementation/test/seam-spike/pr89-case-2-partial-approval.test.ts new file mode 100644 index 000000000..a95ec8e82 --- /dev/null +++ b/reference-implementation/test/seam-spike/pr89-case-2-partial-approval.test.ts @@ -0,0 +1,331 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { createHash, randomBytes } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { executeStreamDetail, StreamDetailVisibilityError } from "../../operations/rs-streams-detail/index.ts"; +import { canonicalConnectorKeyFromManifest } from "../../server/connector-key.ts"; +import { closeDb } from "../../server/db.ts"; +import { startServer } from "../../server/index.ts"; +import { basicIntrospectionAuthorization } from "../../server/introspection-http.ts"; +import { closePostgresStorage } from "../../server/postgres-storage.ts"; +import { + parseGrantedAuthorizationDetail, + parseResolvedGrantApprovedAuthorization, +} from "../../server/source-approved-authorization.ts"; +import { resolveSourceIntrospectionContext } from "../../server/source-introspection-context.ts"; +import { + createPostgresConnectorInstanceStore, + createSqliteConnectorInstanceStore, +} from "../../server/stores/connector-instance-store.ts"; +import { TEST_RS_INTROSPECTION_CREDENTIALS } from "../helpers/introspection-test-credentials.ts"; +import { writePr89CaseOutput } from "./pr89-case-output.ts"; + +const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; +const CONNECTOR_KEY = "pr89-spotify-source-case2"; +const INSTANCE_A = "pr89-case2-account-a"; +const INSTANCE_B = "pr89-case2-account-b"; +const REDIRECT_URI = "https://client.example/pr89-callback"; +const SOURCE_ID = "https://sources.example/records/spotify/case-2"; + +interface CloseableServer { + close: (callback: () => void) => unknown; + closeAllConnections?: () => void; +} + +interface RunningServer { + asPort: number; + asServer: CloseableServer; + rsPort: number; + rsServer: CloseableServer; +} + +function fixture(name: string): Record<string, unknown> { + return JSON.parse(readFileSync(new URL(`./fixtures/pr89/${name}`, import.meta.url), "utf8")) as Record< + string, + unknown + >; +} + +function pkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +async function closeServer(server: RunningServer): Promise<void> { + server.asServer.closeAllConnections?.(); + server.rsServer.closeAllConnections?.(); + await Promise.all([ + new Promise<void>((resolve) => server.asServer.close(() => resolve())), + new Promise<void>((resolve) => server.rsServer.close(() => resolve())), + ]); +} + +async function fetchJson<T = Record<string, unknown>>(url: string | URL, options: RequestInit = {}) { + const response = await fetch(url, options); + const text = await response.text(); + return { body: (text ? JSON.parse(text) : null) as T, response }; +} + +function connectorManifest(declaration: Record<string, unknown>): Record<string, unknown> { + return { + capabilities: { human_interaction: [] }, + connector_id: CONNECTOR_KEY, + connector_key: CONNECTOR_KEY, + display_name: "PR89 Spotify source", + manifest_uri: `https://implementations.example/connectors/${CONNECTOR_KEY}`, + protocol_version: "0.1.0", + source_declaration: declaration, + streams: declaration.streams, + version: "1.0.0", + }; +} + +async function seedInstances(manifest: Record<string, unknown>): Promise<void> { + const connectorId = canonicalConnectorKeyFromManifest(manifest); + assert.equal(connectorId, CONNECTOR_KEY); + const store = POSTGRES_URL ? createPostgresConnectorInstanceStore() : createSqliteConnectorInstanceStore(); + const now = new Date().toISOString(); + await Promise.all( + [INSTANCE_A, INSTANCE_B].map((instanceId) => + store.upsert({ + connectorId, + connectorInstanceId: instanceId, + createdAt: now, + displayName: instanceId, + ownerSubjectId: "owner_local", + sourceBinding: { account: instanceId }, + sourceBindingKey: instanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }) + ) + ); +} + +async function registerClient(asUrl: string): Promise<string> { + const { body, response } = await fetchJson<{ client_id: string }>(`${asUrl}/oauth/register`, { + body: JSON.stringify({ + application_type: "web", + client_name: "PR89 seam client", + grant_types: ["authorization_code"], + redirect_uris: [REDIRECT_URI], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(response.status, 201); + assert.ok(body.client_id); + return body.client_id; +} + +function authorizeUrl(asUrl: string, clientId: string, verifier: string, detail: unknown): URL { + const url = new URL(`${asUrl}/oauth/authorize`); + url.searchParams.set("authorization_details", JSON.stringify([detail])); + url.searchParams.set("client_id", clientId); + url.searchParams.set("code_challenge", pkceChallenge(verifier)); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("state", "pr89-case-2"); + return url; +} + +test("real authorization-code PKCE flow preserves narrowed approval and policy terms", async (t) => { + const tempBase = join(homedir(), ".tmp"); + mkdirSync(tempBase, { recursive: true }); + const tempDir = mkdtempSync(join(tempBase, "pdpp-pr89-case2-")); + const previousDatabaseUrl = process.env.PDPP_DATABASE_URL; + if (POSTGRES_URL) { + process.env.PDPP_DATABASE_URL = POSTGRES_URL; + } else { + delete process.env.PDPP_DATABASE_URL; + } + + let server: RunningServer | null = null; + try { + server = await startServer({ + asPort: 0, + dbPath: join(tempDir, "case-2.sqlite"), + introspectionCallerCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + ownerAuthPassword: "", + quiet: true, + reconcilePolyfillManifests: false, + rsIntrospectionCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + rsPort: 0, + }); + const asUrl = `http://127.0.0.1:${server.asPort}`; + const declaration = fixture("source.json"); + (declaration.source as Record<string, unknown>).id = SOURCE_ID; + const requestDetail = fixture("rar-request.json"); + requestDetail.source = structuredClone(declaration.source); + const requestStreams = requestDetail.streams as Record<string, unknown>[]; + requestStreams[0] = { ...requestStreams[0], instance_ids: [INSTANCE_A] }; + requestStreams[1] = { ...requestStreams[1], instance_ids: [INSTANCE_B] }; + const invalidDetail = fixture("rar-request-invalid.json"); + invalidDetail.source = structuredClone(declaration.source); + const manifest = connectorManifest(declaration); + const registered = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(manifest), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(registered.status, 201); + await seedInstances(manifest); + const clientId = await registerClient(asUrl); + const verifier = randomBytes(32).toString("base64url"); + + const invalidResponse = await fetch(authorizeUrl(asUrl, clientId, verifier, invalidDetail), { + redirect: "manual", + }); + const invalidBody = (await invalidResponse.json()) as { error: string }; + await t.test("invalid Source selection maps to invalid_authorization_details", () => { + assert.equal(invalidResponse.status, 400, JSON.stringify(invalidBody)); + assert.equal(invalidBody.error, "invalid_authorization_details"); + }); + + const authorize = await fetch(authorizeUrl(asUrl, clientId, verifier, requestDetail), { + redirect: "manual", + }); + assert.equal(authorize.status, 302); + const consentLocation = authorize.headers.get("location"); + assert.ok(consentLocation); + const requestUri = new URL(consentLocation, asUrl).searchParams.get("request_uri"); + assert.ok(requestUri); + + const review = await fetchJson<{ approval_review_revision: string }>(`${asUrl}/consent/review`, { + body: JSON.stringify({ + request_uri: requestUri, + source_narrowing: { "0": { streams: ["top_artists"] } }, + subject_id: "owner_local", + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.response.status, 200); + assert.equal(typeof review.body.approval_review_revision, "string"); + + const approval = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: review.body.approval_review_revision, + request_uri: requestUri, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + redirect: "manual", + }); + assert.equal(approval.status, 302); + const callbackLocation = approval.headers.get("location"); + assert.ok(callbackLocation); + const code = new URL(callbackLocation).searchParams.get("code"); + assert.ok(code); + + const { body: tokenBody, response: tokenResponse } = await fetchJson<{ + access_token: string; + authorization_details: unknown[]; + grant_id: string; + }>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: clientId, + code, + code_verifier: verifier, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(tokenResponse.status, 200); + assert.equal(tokenBody.authorization_details.length, 1); + const [grantedDetail] = tokenBody.authorization_details; + assert.ok(grantedDetail); + const parsedDetail = parseGrantedAuthorizationDetail(grantedDetail, declaration); + assert.deepEqual(parsedDetail.detail.retention, { max_duration: "P30D", on_expiry: "delete" }); + assert.equal(parsedDetail.detail.purpose_description, "Build a personal listening summary"); + assert.deepEqual( + parsedDetail.authorization.streams.map((stream) => stream.name), + ["top_artists"] + ); + + const { body: introspected, response: introspectionResponse } = await fetchJson<{ + active: boolean; + authorization_details: unknown[]; + pdpp: unknown; + }>(`${asUrl}/introspect`, { + body: new URLSearchParams({ token: tokenBody.access_token }).toString(), + headers: { + Authorization: basicIntrospectionAuthorization(TEST_RS_INTROSPECTION_CREDENTIALS), + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }); + assert.equal(introspectionResponse.status, 200); + assert.equal(introspected.active, true); + const introspectionContext = resolveSourceIntrospectionContext(introspected); + assert.deepEqual( + parseResolvedGrantApprovedAuthorization(introspectionContext.grant, declaration), + parsedDetail.authorization + ); + + await assert.rejects( + () => + executeStreamDetail( + { + actor: { + client_id: clientId, + grant_id: tokenBody.grant_id, + kind: "client", + subject_id: "owner_local", + }, + streamName: "recently_played", + }, + { + buildStreamMetadata: () => Promise.reject(new Error("declined stream reached metadata assembly")), + getSourceDescriptor: () => declaration.source as { id: string; kind: "connector" }, + hasManifestStream: () => Promise.resolve(true), + isStreamInGrant: (name) => parsedDetail.authorization.streams.some((stream) => stream.name === name), + } + ), + (error: unknown) => error instanceof StreamDetailVisibilityError && error.code === "grant_stream_not_allowed" + ); + + writePr89CaseOutput({ + case_id: "case-2", + observations: [ + "declined_stream_unqueryable", + "partial_approval_preserved", + "policy_terms_preserved", + "source_error_mapped", + ], + oracle_code: "partial_approval", + response_envelopes: [ + { + authorization_details: tokenBody.authorization_details, + status: tokenResponse.status, + }, + { error: invalidBody.error, status: invalidResponse.status }, + { error: "grant_stream_not_allowed", status: 403 }, + ], + schema: "pdpp.pr89.case-output.v1", + }); + } finally { + if (server) { + await closeServer(server); + } + await closePostgresStorage(); + closeDb(); + if (previousDatabaseUrl === undefined) { + delete process.env.PDPP_DATABASE_URL; + } else { + process.env.PDPP_DATABASE_URL = previousDatabaseUrl; + } + rmSync(tempDir, { force: true, recursive: true }); + } +}); diff --git a/reference-implementation/test/seam-spike/pr89-case-3-introspection-context.test.ts b/reference-implementation/test/seam-spike/pr89-case-3-introspection-context.test.ts new file mode 100644 index 000000000..ae0184a2e --- /dev/null +++ b/reference-implementation/test/seam-spike/pr89-case-3-introspection-context.test.ts @@ -0,0 +1,150 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { resolveSourceIntrospectionContext } from "../../server/source-introspection-context.ts"; +import { writePr89CaseOutput } from "./pr89-case-output.ts"; +import { bearer, startPr89OAuthHarness } from "./pr89-oauth-harness.ts"; + +interface MutationFixture { + delete?: boolean; + expected: string; + kind: "credentials" | "request" | "response"; + path?: string; + query?: string; + status?: number; + value?: unknown; +} + +const MUTATION_NAMES = [ + "wrong-credentials", + "wrong-issuer", + "wrong-audience", + "expired", + "stale-cache", + "inactive", + "wrong-context-kind", + "client-mismatch", + "subject-mismatch", + "source-mismatch", + "grant-mismatch", + "rights-missing", + "instance-mismatch", + "field-mismatch", +] as const; + +function mutationFixture(name: string): MutationFixture { + return JSON.parse( + readFileSync(new URL(`./fixtures/pr89/introspection/mutations/${name}.json`, import.meta.url), "utf8") + ) as MutationFixture; +} + +function mutatePath(target: Record<string, unknown>, fixture: MutationFixture): void { + assert.ok(fixture.path); + const parts = fixture.path.split("."); + const leaf = parts.pop(); + assert.ok(leaf); + let owner = target; + for (const part of parts) { + const next = owner[part]; + assert.ok(next && typeof next === "object" && !Array.isArray(next)); + owner = next as Record<string, unknown>; + } + if (fixture.delete) { + delete owner[leaf]; + } else { + owner[leaf] = fixture.value; + } +} + +async function parseJson(response: Response): Promise<Record<string, unknown>> { + const text = await response.text(); + return text ? (JSON.parse(text) as Record<string, unknown>) : {}; +} + +test("authenticated HTTP introspection resolves context and rejects the fixed mutation matrix", async () => { + const harness = await startPr89OAuthHarness(); + const validFixture = JSON.parse( + readFileSync(new URL("./fixtures/pr89/introspection/valid.json", import.meta.url), "utf8") + ) as MutationFixture; + assert.deepEqual(validFixture, { expected: "active", kind: "response" }); + let current: MutationFixture | null = null; + let introspectionCalls = 0; + let capturedResponse: Record<string, unknown> | null = null; + const envelopes: Record<string, unknown>[] = []; + harness.setIntrospectionInterceptor(async (input, init) => { + introspectionCalls += 1; + if (current?.kind === "credentials") { + const headers = new Headers(init?.headers); + headers.set("Authorization", "Basic invalid"); + return fetch(input, { ...init, headers }); + } + const response = await fetch(input, init); + if (!response.ok) { + return response; + } + const body = await parseJson(response); + capturedResponse = structuredClone(body); + if (current?.kind === "response") { + mutatePath(body, current); + } + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + status: response.status, + }); + }); + + try { + current = null; + const valid = await fetch(`${harness.rsUrl}/v1/streams`, { headers: bearer(harness.token) }); + assert.equal(valid.status, 200, JSON.stringify(await parseJson(valid.clone()))); + assert.equal(introspectionCalls, 1); + assert.ok(capturedResponse); + const resolved = resolveSourceIntrospectionContext(capturedResponse); + const grant = resolved.grant as { streams: Record<string, unknown>[] }; + const approvedStream = grant.streams.find((stream) => stream.name === "top_artists"); + assert.deepEqual(approvedStream?.instance_ids, [harness.instanceId]); + assert.deepEqual(approvedStream?.fields, ["id", "name", "observed_at"]); + assert.deepEqual(approvedStream?.time_constraint, { + field: "observed_at", + since: "2026-01-01T00:00:00Z", + until: "2026-04-01T00:00:00Z", + }); + assert.deepEqual(approvedStream?.resources, ["artist:42"]); + envelopes.push({ fixture: "valid", status: valid.status }); + + for (const name of MUTATION_NAMES) { + current = mutationFixture(name); + const before: number = introspectionCalls; + const query = current.kind === "request" ? `?${current.query}` : ""; + // biome-ignore lint/performance/noAwaitInLoops: The mutable interceptor must apply one named mutation at a time. + const response = await fetch(`${harness.rsUrl}/v1/streams/top_artists/records${query}`, { + headers: bearer(harness.token), + }); + const body = await parseJson(response); + const error = body.error as Record<string, unknown> | undefined; + assert.equal(response.status, current.status ?? 401, `${name}: ${JSON.stringify(body)}`); + assert.equal(error?.code, current.expected, name); + assert.equal(introspectionCalls, before + 1, name); + envelopes.push({ error: error?.code, fixture: name, status: response.status }); + } + + writePr89CaseOutput({ + case_id: "case-3", + observations: [ + "authenticated_http_introspection", + "complete_context_resolved", + "mutation_matrix_rejected", + "one_http_introspection_no_fallback", + ], + oracle_code: "context_resolved", + response_envelopes: envelopes, + schema: "pdpp.pr89.case-output.v1", + }); + } finally { + await harness.close(); + } +}); diff --git a/reference-implementation/test/seam-spike/pr89-case-4-response-only.test.ts b/reference-implementation/test/seam-spike/pr89-case-4-response-only.test.ts new file mode 100644 index 000000000..fc082b419 --- /dev/null +++ b/reference-implementation/test/seam-spike/pr89-case-4-response-only.test.ts @@ -0,0 +1,126 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { resolveSourceIntrospectionContext } from "../../server/source-introspection-context.ts"; +import { writePr89CaseOutput } from "./pr89-case-output.ts"; +import { bearer, startPr89OAuthHarness } from "./pr89-oauth-harness.ts"; + +async function jsonBody(response: Response): Promise<Record<string, unknown>> { + const text = await response.text(); + return text ? (JSON.parse(text) as Record<string, unknown>) : {}; +} + +test("captured introspection context enforces the response-only request matrix with AS disabled", async () => { + const harness = await startPr89OAuthHarness(); + const records = JSON.parse(readFileSync(new URL("./fixtures/pr89/records.json", import.meta.url), "utf8")) as Record< + string, + Parameters<typeof harness.ingest>[0] + >; + assert.ok(records.allowed && records.outside_resource && records.outside_time); + await harness.ingest(records.allowed); + await harness.ingest(records.outside_resource); + let captured: Record<string, unknown> | null = null; + harness.setIntrospectionInterceptor(async (input, init) => { + const response = await fetch(input, init); + const body = await jsonBody(response); + captured = structuredClone(body); + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + status: response.status, + }); + }); + + try { + const captureRequest = await fetch(`${harness.rsUrl}/v1/streams`, { headers: bearer(harness.token) }); + assert.equal(captureRequest.status, 200); + assert.ok(captured); + const resolved = resolveSourceIntrospectionContext(captured); + const grant = resolved.grant as { streams: Record<string, unknown>[] }; + const stream = grant.streams.find((candidate) => candidate.name === "top_artists"); + assert.deepEqual(stream?.instance_ids, [harness.instanceId]); + assert.deepEqual(stream?.time_constraint, { + field: "observed_at", + since: "2026-01-01T00:00:00Z", + until: "2026-04-01T00:00:00Z", + }); + + let responseOnlyReads = 0; + harness.setIntrospectionInterceptor(() => { + responseOnlyReads += 1; + return Promise.resolve( + new Response(JSON.stringify(captured), { + headers: { "Content-Type": "application/json" }, + status: 200, + }) + ); + }); + await harness.disableAuthorizationServer(); + + const recordsUrl = `${harness.rsUrl}/v1/streams/top_artists/records?connection_id=${encodeURIComponent(harness.instanceId)}`; + const allowed = await fetch(recordsUrl, { headers: bearer(harness.token) }); + assert.equal(allowed.status, 200); + const allowedBody = await jsonBody(allowed); + const allowedData = allowedBody.data as Record<string, unknown>[]; + assert.deepEqual( + allowedData.map((record) => record.id), + ["artist:42"] + ); + + const deniedRequests = [ + { + code: "context.stream_not_allowed", + name: "stream", + status: 401, + url: `${harness.rsUrl}/v1/streams/recently_played/records`, + }, + { + code: "context.instance_mismatch", + name: "instance", + status: 401, + url: `${harness.rsUrl}/v1/streams/top_artists/records?connection_id=account-b`, + }, + { + code: "unknown_field", + name: "field", + status: 400, + url: `${harness.rsUrl}/v1/streams/top_artists/records?fields=private_note`, + }, + ] as const; + const deniedEnvelopes = await Promise.all( + deniedRequests.map(async (denied) => { + const response = await fetch(denied.url, { headers: bearer(harness.token) }); + const body = await jsonBody(response); + const error = body.error as Record<string, unknown> | undefined; + assert.equal(response.status, denied.status, `${denied.name}: ${JSON.stringify(body)}`); + assert.equal(error?.code, denied.code, denied.name); + return { error: error?.code, matrix: denied.name, status: response.status }; + }) + ); + const envelopes: Record<string, unknown>[] = [{ matrix: "allowed", status: allowed.status }, ...deniedEnvelopes]; + + await harness.ingest(records.outside_time); + const outsideTime = await fetch(recordsUrl, { headers: bearer(harness.token) }); + assert.equal(outsideTime.status, 200); + const outsideTimeBody = await jsonBody(outsideTime); + assert.deepEqual(outsideTimeBody.data, []); + envelopes.push( + { matrix: "resource_omitted", status: allowed.status }, + { matrix: "time_omitted", status: outsideTime.status } + ); + assert.equal(responseOnlyReads, 5); + + writePr89CaseOutput({ + case_id: "case-4", + observations: ["allowed_matrix_passed", "as_disabled", "denied_matrix_passed", "response_only_enforcement"], + oracle_code: "response_only", + response_envelopes: envelopes, + schema: "pdpp.pr89.case-output.v1", + }); + } finally { + await harness.close(); + } +}); diff --git a/reference-implementation/test/seam-spike/pr89-case-5-lifecycle.test.ts b/reference-implementation/test/seam-spike/pr89-case-5-lifecycle.test.ts new file mode 100644 index 000000000..61043e7f1 --- /dev/null +++ b/reference-implementation/test/seam-spike/pr89-case-5-lifecycle.test.ts @@ -0,0 +1,57 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import test from "node:test"; + +const REFERENCE_ROOT = resolve(import.meta.dirname, "../.."); + +function runTestFile(file: string): Promise<string> { + const childEnv = { ...process.env }; + childEnv.NODE_TEST_CONTEXT = undefined; + return new Promise((resolveOutput, reject) => { + const child = spawn(process.execPath, ["--import", "tsx", "--test", file], { + cwd: REFERENCE_ROOT, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code !== 0 || signal) { + reject(new Error(`lifecycle proof failed (${code ?? "signal"}:${signal ?? "none"})\n${output}`)); + return; + } + resolveOutput(output); + }); + }); +} + +test("authorization and refresh lifecycle portfolio passes on PostgreSQL", async () => { + assert.ok(process.env.PDPP_TEST_POSTGRES_URL, "live PostgreSQL is required"); + const output = [ + await runTestFile("test/token-refresh-postgres-path.test.ts"), + await runTestFile("test/grant-package-postgres-path.test.ts"), + ].join("\n"); + for (const required of [ + "authorization-code redemption has one PostgreSQL race winner", + "authorization-code failure rolls back PostgreSQL consumption with initial refresh issuance", + "authorization-code delivery converges and recovers on PostgreSQL", + "single-use grant issuance has one PostgreSQL race winner", + "PostgreSQL migration revokes unlinked legacy refresh families and bound bearers", + "PostgreSQL supersede failure rolls back the newly inserted family bearer", + "PostgreSQL token lifetime and refresh eligibility follow the persisted grant contract", + "authorization-code exchange + refresh rotation through real auth.js postgres adapters", + "package refresh replay deactivates every family-linked bearer through real postgres adapters", + ]) { + assert.match(output, new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } +}); diff --git a/reference-implementation/test/seam-spike/pr89-case-8-durable-handoff.test.ts b/reference-implementation/test/seam-spike/pr89-case-8-durable-handoff.test.ts new file mode 100644 index 000000000..a098ff0d5 --- /dev/null +++ b/reference-implementation/test/seam-spike/pr89-case-8-durable-handoff.test.ts @@ -0,0 +1,227 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import test from "node:test"; + +const REFERENCE_ROOT = resolve(import.meta.dirname, "../.."); + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function runNodeTests( + file: string, + testNames: readonly string[], + opts: { forceSqlite?: boolean } = {} +): Promise<string> { + const childEnv = { ...process.env }; + childEnv.NODE_TEST_CONTEXT = undefined; + if (opts.forceSqlite) { + childEnv.DATABASE_URL = undefined; + childEnv.PDPP_DATABASE_URL = undefined; + childEnv.PDPP_STORAGE_BACKEND = undefined; + childEnv.PDPP_TEST_POSTGRES_URL = undefined; + } + const pattern = `^(${testNames.map(escapeRegExp).join("|")})$`; + return new Promise((resolveOutput, reject) => { + const child = spawn(process.execPath, ["--import", "tsx", "--test", "--test-name-pattern", pattern, file], { + cwd: REFERENCE_ROOT, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code !== 0 || signal) { + reject(new Error(`durable handoff focused tests failed (${code ?? "signal"}:${signal ?? "none"})\n${output}`)); + return; + } + resolveOutput(output); + }); + }); +} + +async function assertFocusedTestsPass(file: string, testNames: readonly string[]): Promise<void> { + const output = await runNodeTests(file, testNames); + for (const required of testNames) { + assert.match(output, new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } +} + +async function assertFocusedNestedTestsPass( + file: string, + parentTestName: string, + testNames: readonly string[], + opts: { forceSqlite?: boolean } = {} +): Promise<void> { + const output = await runNodeTests(file, [parentTestName, ...testNames], opts); + for (const required of testNames) { + assert.match(output, new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } +} + +test("owner-device-approval-atomicity: rollback, owner concurrency, and cross-subject recovery", async () => { + await assertFocusedTestsPass("test/owner-device-approval-atomicity.test.ts", [ + "owner-device approval rolls back when token insertion has not started", + "owner-device approval rolls back token insert and events on mid-transaction failure", + "owner-device approval retry after rollback mints exactly one introspectable owner token", + "owner-device dynamic client binding rolls back with failed approval", + "owner-device approval is idempotent across concurrent approval and response-loss retry", + "owner-device approval recovery rejects a different authenticated subject", + "owner-device approval allows only the claimed subject under mixed concurrent calls", + ]); +}); + +test("terminal decisions: SQLite approval and denial arbitrate without contradictory evidence", async () => { + await assertFocusedTestsPass("test/as-device-decision-outcome-pure.test.ts", [ + "executeAsDeviceDecision: approval_conflict maps to HTTP 409 and preserves trace ids", + ]); + await assertFocusedTestsPass("test/owner-device-approval-atomicity.test.ts", [ + "owner-device approval wins a denial race without contradictory rejection", + "owner-device denial wins before approval and denial event rolls back on failure", + "owner-device mixed approval and denial contention has one durable terminal outcome", + ]); + await assertFocusedNestedTestsPass( + "test/security-consent-token-handoff.test.ts", + "security: harden consent token handoff", + [ + "ordinary approval wins a paused denial without contradictory denial evidence", + "ordinary denial is terminal and rolls back its event on transaction failure", + "ordinary mixed approval and denial contention has one terminal outcome", + ], + { forceSqlite: true } + ); +}); + +test("terminal decisions: live PostgreSQL approval and denial arbitrate atomically", async () => { + assert.ok(process.env.PDPP_TEST_POSTGRES_URL, "live PostgreSQL is required"); + await assertFocusedTestsPass("test/auth-consent-device-postgres-path.test.ts", [ + "owner device authorization: approve and deny arbitrate one terminal decision on postgres", + "pending consent: approve and deny arbitrate atomically with rollback on postgres", + ]); +}); + +test("agent-cli: crash recovery from committed pending approval", async () => { + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: approval committed before completion recovers at poll time", + ]); +}); + +test("agent-connect: registration response is cache-safe", async () => { + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: registration 201 carries credential no-store headers", + ]); +}); + +test("agent-connect: denial response is bounded", async () => { + await assertFocusedTestsPass("test/agent-cli.test.ts", ["agent-connect: owner denial returns bounded access_denied"]); +}); + +test("agent-connect: denial is durable across approval_id and completion failure", async () => { + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: approval_id denial projects to polling", + "agent-connect: denial completion failure is reconciled during polling", + "agent-connect: expired consent projects to bounded expired_token polling", + ]); +}); + +test("agent-connect: live PostgreSQL denial is durable across approval_id and restart", async () => { + assert.ok(process.env.PDPP_TEST_POSTGRES_URL, "live PostgreSQL is required"); + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: live Postgres denial projects and recovers after completion failure", + ]); +}); + +test("agent-cli: crash-completed expiry and prune revoke committed approvals", async () => { + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: crash-completed approval that expires before poll revokes committed token", + "agent-connect: prune reconciles crash-completed expired approval before deleting attempt", + ]); +}); + +test("agent-cli: cleanup/approval race revokes committed token", async () => { + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: cleanup miss racing approval commit revokes the committed token", + "agent-connect: approval after cleanup second miss before tombstone is revoked", + "agent-connect: approval completion after tombstone revokes its token", + ]); +}); + +test("agent-cli: response-loss replay survives unrelated registration", async () => { + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: owner approval completes polling without exposing owner token", + ]); +}); + +test("agent-cli: approved-after-expiry revokes bearer and expired bearer is refused", async () => { + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: expired polling handle returns bounded expired_token", + "agent-connect: approved attempt that expires before delivery revokes the stranded bearer", + ]); +}); + +test("agent-cli: revoked bearer is refused before delivery", async () => { + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: approved attempt fails closed when the grant is revoked before delivery", + ]); +}); + +test("agent-cli: cache headers reject invalid bearer without token disclosure", async () => { + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: schema verification fails cleanly for invalid bearer", + ]); +}); + +test("agent-cli: live PostgreSQL approved expiry and revocation fail closed before delivery", async () => { + assert.ok(process.env.PDPP_TEST_POSTGRES_URL, "live PostgreSQL is required"); + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: live Postgres approved expiry and revocation fail closed before delivery", + ]); +}); + +test("agent-cli: live PostgreSQL crash expiry/prune and response-loss replay", async () => { + assert.ok(process.env.PDPP_TEST_POSTGRES_URL, "live PostgreSQL is required"); + await assertFocusedTestsPass("test/agent-cli.test.ts", [ + "agent-connect: live Postgres response-loss retry survives unrelated registration", + "agent-connect: live Postgres crash-completed expiry and prune revoke committed tokens", + "agent-connect: live Postgres cleanup miss racing approval commit revokes committed token", + "agent-connect: live Postgres expiry CAS interleavings revoke committed tokens", + ]); +}); + +test("consent-exchange: SQLite restart, single-use, and response-loss recovery", async () => { + await assertFocusedNestedTestsPass( + "test/security-consent-token-handoff.test.ts", + "security: harden consent token handoff", + [ + "concurrent SQLite redemptions converge on one stored transition", + "an already-committed approval can create a fresh HTML handoff", + "an exchange code survives a SQLite-backed server restart", + ] + ); +}); + +test("batch consent: package handoff and revocation are durable", async () => { + await assertFocusedTestsPass("test/batch-consent-per-source-gate.test.ts", [ + "batch consent terminal decision is exclusive across approval and denial", + "batch consent gate: HTML approval hands off the package token durably", + "batch consent gate: a revoked package is not delivered by a stored exchange code", + ]); +}); + +test("auth consent device PostgreSQL: concurrent redemption and package revocation", async () => { + assert.ok(process.env.PDPP_TEST_POSTGRES_URL, "live PostgreSQL is required"); + await assertFocusedTestsPass("test/auth-consent-device-postgres-path.test.ts", [ + "consent handoff: concurrent Postgres redemption converges on one persisted token", + "consent handoff: Postgres package delivery works and revocation fails closed", + ]); +}); diff --git a/reference-implementation/test/seam-spike/pr89-case-output.ts b/reference-implementation/test/seam-spike/pr89-case-output.ts new file mode 100644 index 000000000..50cbd7a3a --- /dev/null +++ b/reference-implementation/test/seam-spike/pr89-case-output.ts @@ -0,0 +1,24 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { writeFileSync } from "node:fs"; + +import { canonicalJson, type Json } from "../../scripts/pr89-seam-evidence-contract.ts"; + +export interface Pr89CaseOutput { + case_id: `case-${1 | 2 | 3 | 4}`; + observations: string[]; + oracle_code: string; + response_envelopes: unknown[]; + schema: "pdpp.pr89.case-output.v1"; +} + +export function writePr89CaseOutput(output: Pr89CaseOutput): void { + const outputPath = process.env.PDPP_PR89_CASE_OUTPUT_PATH; + if (!outputPath) { + return; + } + const observations = [...new Set(output.observations)].sort(); + const canonical = canonicalJson({ ...output, observations } as unknown as Json); + writeFileSync(outputPath, `${canonical}\n`, { encoding: "utf8", mode: 0o600 }); +} diff --git a/reference-implementation/test/seam-spike/pr89-gnap-map.test.ts b/reference-implementation/test/seam-spike/pr89-gnap-map.test.ts new file mode 100644 index 000000000..084cb5ac9 --- /dev/null +++ b/reference-implementation/test/seam-spike/pr89-gnap-map.test.ts @@ -0,0 +1,129 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const FIXTURE_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures/pr89/gnap"); + +type AccessMode = "continuous" | "single_use"; + +interface ApprovedStream { + fields: string[]; + instance_ids: string[]; + name: string; + resources?: string[]; + time_constraint?: { + field: string; + since?: string; + until?: string; + }; +} + +interface ApprovedAuthorization { + access_mode: AccessMode; + source_id: string; + streams: ApprovedStream[]; +} + +interface GnapFixture { + access: ApprovedAuthorization & { + must_understand?: string[]; + type: string; + [key: string]: unknown; + }; +} + +interface ControlMapRow { + control: string; + status: "GNAP-native but binding-owned" | "mapped" | "not demonstrated"; +} + +const CONTROL_MAP: ControlMapRow[] = [ + { control: "approved rights", status: "mapped" }, + { control: "narrowed approval", status: "mapped" }, + { control: "unknown mandatory members", status: "mapped" }, + { control: "client instance", status: "GNAP-native but binding-owned" }, + { control: "subject identity", status: "GNAP-native but binding-owned" }, + { control: "proof confirmation", status: "not demonstrated" }, + { control: "continuation interaction", status: "not demonstrated" }, +]; + +function readFixture(name: string): GnapFixture { + return JSON.parse(readFileSync(join(FIXTURE_DIR, name), "utf8")) as GnapFixture; +} + +function sortedUnique(values: string[], code: string): string[] { + assert.ok(values.length > 0, code); + assert.equal(new Set(values).size, values.length, code); + return [...values].sort(); +} + +function parseGnapAccess(fixture: GnapFixture): ApprovedAuthorization { + const { access } = fixture; + assert.equal(access.type, "pdpp-approved-authorization"); + const mandatory = access.must_understand ?? []; + const known = new Set(["type", "source_id", "access_mode", "streams", "must_understand"]); + for (const member of mandatory) { + if (!known.has(member)) { + const error = new Error("gnap.unknown_mandatory_member") as Error & { code?: string }; + error.code = "gnap.unknown_mandatory_member"; + throw error; + } + } + assert.ok(access.source_id.length > 0, "auth.source_id_empty"); + assert.ok(access.access_mode === "single_use" || access.access_mode === "continuous", "auth.access_mode_invalid"); + assert.ok(access.streams.length > 0, "auth.streams_empty"); + return { + access_mode: access.access_mode, + source_id: access.source_id, + streams: access.streams.map((stream) => ({ + fields: sortedUnique(stream.fields, "auth.field_duplicate"), + instance_ids: sortedUnique(stream.instance_ids, "auth.instance_id_duplicate"), + name: stream.name, + ...(stream.resources ? { resources: sortedUnique(stream.resources, "auth.resource_duplicate") } : {}), + ...(stream.time_constraint ? { time_constraint: stream.time_constraint } : {}), + })), + }; +} + +function toGnapAccess(rights: ApprovedAuthorization): GnapFixture { + return { + access: { + type: "pdpp-approved-authorization", + ...rights, + }, + }; +} + +test("GNAP approved rights round-trip without changing neutral rights", () => { + const rights = parseGnapAccess(readFixture("approved.json")); + assert.deepEqual(parseGnapAccess(toGnapAccess(rights)), rights); +}); + +test("GNAP partial approval is represented as narrowed neutral rights", () => { + const approved = parseGnapAccess(readFixture("approved.json")); + const partial = parseGnapAccess(readFixture("partial.json")); + assert.equal(partial.streams.length, 1); + assert.equal(partial.streams[0]?.name, "top_artists"); + assert.ok(approved.streams.some((stream) => stream.name === "recently_played")); + assert.ok(!partial.streams.some((stream) => stream.name === "recently_played")); + assert.deepEqual(parseGnapAccess(toGnapAccess(partial)), partial); +}); + +test("GNAP rejects unknown mandatory members", () => { + assert.throws(() => parseGnapAccess(readFixture("unknown-mandatory.json")), { + code: "gnap.unknown_mandatory_member", + }); +}); + +test("GNAP control map does not count not-demonstrated controls as passed", () => { + assert.ok(CONTROL_MAP.some((row) => row.status === "not demonstrated")); + assert.deepEqual( + CONTROL_MAP.filter((row) => row.status === "not demonstrated").map((row) => row.control), + ["proof confirmation", "continuation interaction"] + ); +}); diff --git a/reference-implementation/test/seam-spike/pr89-oauth-harness.ts b/reference-implementation/test/seam-spike/pr89-oauth-harness.ts new file mode 100644 index 000000000..1109edb08 --- /dev/null +++ b/reference-implementation/test/seam-spike/pr89-oauth-harness.ts @@ -0,0 +1,306 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { createHash, randomBytes } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { canonicalConnectorKeyFromManifest } from "../../server/connector-key.ts"; +import { closeDb } from "../../server/db.ts"; +import { startServer } from "../../server/index.ts"; +import { closePostgresStorage } from "../../server/postgres-storage.ts"; +import { ingestRecord } from "../../server/records.ts"; +import { + createPostgresConnectorInstanceStore, + createSqliteConnectorInstanceStore, +} from "../../server/stores/connector-instance-store.ts"; +import { TEST_RS_INTROSPECTION_CREDENTIALS } from "../helpers/introspection-test-credentials.ts"; + +const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; +const REDIRECT_URI = "https://client.example/pr89-callback"; + +interface CloseableServer { + close: (callback: () => void) => unknown; + closeAllConnections?: () => void; +} + +interface RunningServer { + asPort: number; + asServer: CloseableServer; + rsPort: number; + rsServer: CloseableServer; +} + +type IntrospectionInterceptor = ( + input: Parameters<typeof fetch>[0], + init: Parameters<typeof fetch>[1] +) => Promise<Response>; + +export interface Pr89OAuthHarness { + readonly asUrl: string; + readonly clientId: string; + close: () => Promise<void>; + readonly declaration: Record<string, unknown>; + disableAuthorizationServer: () => Promise<void>; + readonly grantId: string; + ingest: (record: Parameters<typeof ingestRecord>[1]) => Promise<void>; + readonly instanceId: string; + readonly rsUrl: string; + setIntrospectionInterceptor: (interceptor: IntrospectionInterceptor | null) => void; + readonly token: string; +} + +function fixture(name: string): Record<string, unknown> { + return JSON.parse(readFileSync(new URL(`./fixtures/pr89/${name}`, import.meta.url), "utf8")) as Record< + string, + unknown + >; +} + +function pkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +async function closeOne(server: CloseableServer): Promise<void> { + server.closeAllConnections?.(); + await new Promise<void>((resolve) => server.close(() => resolve())); +} + +async function fetchJson<T = Record<string, unknown>>(url: string | URL, options: RequestInit = {}) { + const response = await fetch(url, options); + const text = await response.text(); + return { body: (text ? JSON.parse(text) : null) as T, response }; +} + +function manifest(declaration: Record<string, unknown>, connectorKey: string): Record<string, unknown> { + return { + capabilities: { human_interaction: [] }, + connector_id: connectorKey, + connector_key: connectorKey, + display_name: "PR89 Spotify source", + manifest_uri: `https://implementations.example/connectors/${connectorKey}`, + protocol_version: "0.1.0", + source_declaration: declaration, + streams: declaration.streams, + version: "1.0.0", + }; +} + +async function seedInstances( + connectorManifest: Record<string, unknown>, + connectorKey: string, + instanceIds: readonly string[] +): Promise<void> { + const connectorId = canonicalConnectorKeyFromManifest(connectorManifest); + assert.equal(connectorId, connectorKey); + const store = POSTGRES_URL ? createPostgresConnectorInstanceStore() : createSqliteConnectorInstanceStore(); + const now = new Date().toISOString(); + await Promise.all( + instanceIds.map((instanceId) => + store.upsert({ + connectorId, + connectorInstanceId: instanceId, + createdAt: now, + displayName: instanceId, + ownerSubjectId: "owner_local", + sourceBinding: { account: instanceId }, + sourceBindingKey: instanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }) + ) + ); +} + +async function registerClient(asUrl: string): Promise<string> { + const { body, response } = await fetchJson<{ client_id: string }>(`${asUrl}/oauth/register`, { + body: JSON.stringify({ + application_type: "web", + client_name: "PR89 seam client", + grant_types: ["authorization_code"], + redirect_uris: [REDIRECT_URI], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(response.status, 201); + assert.ok(body.client_id); + return body.client_id; +} + +function authorizationUrl(asUrl: string, clientId: string, verifier: string, detail: unknown): URL { + const url = new URL(`${asUrl}/oauth/authorize`); + url.searchParams.set("authorization_details", JSON.stringify([detail])); + url.searchParams.set("client_id", clientId); + url.searchParams.set("code_challenge", pkceChallenge(verifier)); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("state", "pr89-seam"); + return url; +} + +async function issueToken( + asUrl: string, + clientId: string, + detail: unknown +): Promise<{ grantId: string; token: string }> { + const verifier = randomBytes(32).toString("base64url"); + const authorize = await fetch(authorizationUrl(asUrl, clientId, verifier, detail), { redirect: "manual" }); + assert.equal(authorize.status, 302, await authorize.clone().text()); + const consentLocation = authorize.headers.get("location"); + assert.ok(consentLocation); + const requestUri = new URL(consentLocation, asUrl).searchParams.get("request_uri"); + assert.ok(requestUri); + const review = await fetchJson<{ approval_review_revision: string }>(`${asUrl}/consent/review`, { + body: JSON.stringify({ + request_uri: requestUri, + source_narrowing: { "0": { streams: ["top_artists"] } }, + subject_id: "owner_local", + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.response.status, 200, JSON.stringify(review.body)); + assert.equal(typeof review.body.approval_review_revision, "string"); + const approval = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: review.body.approval_review_revision, + request_uri: requestUri, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + redirect: "manual", + }); + assert.equal(approval.status, 302); + const callbackLocation = approval.headers.get("location"); + assert.ok(callbackLocation); + const code = new URL(callbackLocation).searchParams.get("code"); + assert.ok(code); + const { body, response } = await fetchJson<{ access_token: string; grant_id: string }>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: clientId, + code, + code_verifier: verifier, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(response.status, 200); + assert.ok(body.access_token); + assert.ok(body.grant_id); + return { grantId: body.grant_id, token: body.access_token }; +} + +export async function startPr89OAuthHarness(): Promise<Pr89OAuthHarness> { + const tempBase = join(homedir(), ".tmp"); + mkdirSync(tempBase, { recursive: true }); + const tempDir = mkdtempSync(join(tempBase, "pdpp-pr89-seam-")); + const previousDatabaseUrl = process.env.PDPP_DATABASE_URL; + if (POSTGRES_URL) { + process.env.PDPP_DATABASE_URL = POSTGRES_URL; + } else { + delete process.env.PDPP_DATABASE_URL; + } + let interceptor: IntrospectionInterceptor | null = null; + const introspectionFetch: typeof fetch = (input, init) => + interceptor ? interceptor(input, init) : fetch(input, init); + let server: RunningServer | null = null; + try { + server = await startServer({ + asPort: 0, + dbPath: join(tempDir, "seam.sqlite"), + introspectionCallerCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + introspectionFetch, + ownerAuthPassword: "", + quiet: true, + reconcilePolyfillManifests: false, + rsIntrospectionCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + rsPort: 0, + }); + const asUrl = `http://127.0.0.1:${server.asPort}`; + const rsUrl = `http://127.0.0.1:${server.rsPort}`; + const suffix = `${process.pid}-${randomBytes(4).toString("hex")}`; + const connectorKey = `pr89-seam-${suffix}`; + const instanceId = `account-a-${suffix}`; + const optionalInstanceId = `account-b-${suffix}`; + const declaration = fixture("source.json"); + const declarationSource = declaration.source as Record<string, unknown>; + declarationSource.id = `https://sources.example/records/spotify/${suffix}`; + const requestDetail = fixture("rar-request.json"); + requestDetail.source = structuredClone(declarationSource); + const requestStreams = requestDetail.streams as Record<string, unknown>[]; + requestStreams[0] = { ...requestStreams[0], instance_ids: [instanceId] }; + requestStreams[1] = { ...requestStreams[1], instance_ids: [optionalInstanceId] }; + const connectorManifest = manifest(declaration, connectorKey); + const registered = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(connectorManifest), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(registered.status, 201); + await seedInstances(connectorManifest, connectorKey, [instanceId, optionalInstanceId]); + const clientId = await registerClient(asUrl); + const { grantId, token } = await issueToken(asUrl, clientId, requestDetail); + let asClosed = false; + return { + asUrl, + clientId, + close: async () => { + if (!asClosed) { + await closeOne(server?.asServer as CloseableServer); + } + await closeOne(server?.rsServer as CloseableServer); + await closePostgresStorage(); + closeDb(); + if (previousDatabaseUrl === undefined) { + delete process.env.PDPP_DATABASE_URL; + } else { + process.env.PDPP_DATABASE_URL = previousDatabaseUrl; + } + rmSync(tempDir, { force: true, recursive: true }); + }, + declaration, + disableAuthorizationServer: async () => { + if (!asClosed) { + await closeOne(server?.asServer as CloseableServer); + asClosed = true; + } + }, + grantId, + ingest: async (record) => { + await ingestRecord({ connector_id: connectorKey, connector_instance_id: instanceId }, record); + }, + instanceId, + rsUrl, + setIntrospectionInterceptor: (next) => { + interceptor = next; + }, + token, + }; + } catch (error: unknown) { + if (server) { + await Promise.all([closeOne(server.asServer), closeOne(server.rsServer)]); + } + await closePostgresStorage(); + closeDb(); + if (previousDatabaseUrl === undefined) { + delete process.env.PDPP_DATABASE_URL; + } else { + process.env.PDPP_DATABASE_URL = previousDatabaseUrl; + } + rmSync(tempDir, { force: true, recursive: true }); + throw error; + } +} + +export function bearer(token: string): Record<string, string> { + return { Authorization: `Bearer ${token}` }; +} diff --git a/reference-implementation/test/seam-spike/pr89-receipt.schema.json b/reference-implementation/test/seam-spike/pr89-receipt.schema.json new file mode 100644 index 000000000..895295109 --- /dev/null +++ b/reference-implementation/test/seam-spike/pr89-receipt.schema.json @@ -0,0 +1,155 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "case": { + "additionalProperties": false, + "properties": { + "case_output_digest": { "$ref": "#/$defs/digest" }, + "evidence_digest": { "$ref": "#/$defs/digest" }, + "fixtures_digest": { "$ref": "#/$defs/digest" }, + "implementation_inputs_digest": { "$ref": "#/$defs/digest" }, + "oracle_code": { + "enum": [ + "authorization_state.unsupported_legacy_shape", + "context_resolved", + "durable_handoff", + "equal", + "gnap_map", + "partial_approval", + "races_and_refresh", + "response_only" + ] + }, + "status": { "const": "pass" }, + "terminal_events_digest": { "$ref": "#/$defs/digest" }, + "test_file_digest": { "$ref": "#/$defs/digest" } + }, + "required": [ + "case_output_digest", + "evidence_digest", + "fixtures_digest", + "implementation_inputs_digest", + "oracle_code", + "status", + "terminal_events_digest", + "test_file_digest" + ], + "type": "object" + }, + "digest": { "pattern": "^sha256:[a-f0-9]{64}$", "type": "string" } + }, + "additionalProperties": false, + "properties": { + "assertions": { + "additionalProperties": false, + "properties": { + "authenticated_http_introspection": { "const": true }, + "durable_post_approval_handoff": { "const": true }, + "fresh_authorization_required": { "const": true }, + "legacy_refresh_state_rejected": { "const": true }, + "no_in_process_fallback": { "const": true }, + "postgresql_races": { "const": true }, + "refresh_family_access_tokens_inactive_on_replay": { "const": true }, + "refresh_family_revoked_on_replay": { "const": true }, + "response_only_enforcement": { "const": true } + }, + "required": [ + "authenticated_http_introspection", + "durable_post_approval_handoff", + "fresh_authorization_required", + "legacy_refresh_state_rejected", + "no_in_process_fallback", + "postgresql_races", + "refresh_family_access_tokens_inactive_on_replay", + "refresh_family_revoked_on_replay", + "response_only_enforcement" + ], + "type": "object" + }, + "backend": { "const": "postgresql" }, + "cases": { + "additionalProperties": false, + "properties": { + "case-1": { "$ref": "#/$defs/case" }, + "case-2": { "$ref": "#/$defs/case" }, + "case-3": { "$ref": "#/$defs/case" }, + "case-4": { "$ref": "#/$defs/case" }, + "case-5": { "$ref": "#/$defs/case" }, + "case-6": { "$ref": "#/$defs/case" }, + "case-7": { "$ref": "#/$defs/case" }, + "case-8": { "$ref": "#/$defs/case" } + }, + "required": [ + "case-1", + "case-2", + "case-3", + "case-4", + "case-5", + "case-6", + "case-7", + "case-8" + ], + "type": "object" + }, + "clock": { "const": "2026-08-11T12:00:00Z" }, + "command": { + "const": "pnpm --filter pdpp-reference-implementation test:seam:pr89 -- --backend postgresql" + }, + "decisions": { + "additionalProperties": false, + "properties": { + "approved_authorization_shape": { "const": "pass" }, + "authorization_context_composition": { "const": "pass" }, + "binding_separation": { "const": "pass" } + }, + "required": [ + "approved_authorization_shape", + "authorization_context_composition", + "binding_separation" + ], + "type": "object" + }, + "evidence_tree_digest": { "$ref": "#/$defs/digest" }, + "fixtures_digest": { "$ref": "#/$defs/digest" }, + "hardening": { + "additionalProperties": false, + "properties": { + "code_reuse_revocation": { "const": "separately_reported" }, + "dpop": { "const": "not_demonstrated" }, + "keyless_recovery": { "const": "deferred" }, + "refresh_rotation": { "const": "pass" }, + "security_profile_floor": { "const": "deferred" } + }, + "required": [ + "code_reuse_revocation", + "dpop", + "keyless_recovery", + "refresh_rotation", + "security_profile_floor" + ], + "type": "object" + }, + "implementation_inputs_digest": { "$ref": "#/$defs/digest" }, + "relevant_file_tree_digest": { "$ref": "#/$defs/digest" }, + "response_envelopes_digest": { "$ref": "#/$defs/digest" }, + "schema": { "const": "pdpp.pr89.receipt.v2" }, + "undecided_common_schemas": { "const": true } + }, + "required": [ + "assertions", + "backend", + "cases", + "clock", + "command", + "decisions", + "evidence_tree_digest", + "fixtures_digest", + "hardening", + "implementation_inputs_digest", + "relevant_file_tree_digest", + "response_envelopes_digest", + "schema", + "undecided_common_schemas" + ], + "type": "object" +} diff --git a/reference-implementation/test/search-fan-in-host-shell.test.ts b/reference-implementation/test/search-fan-in-host-shell.test.ts index a1436fe87..47f1b88db 100644 --- a/reference-implementation/test/search-fan-in-host-shell.test.ts +++ b/reference-implementation/test/search-fan-in-host-shell.test.ts @@ -67,6 +67,7 @@ const baseManifest = { capabilities: { human_interaction: [] }, connector_id: CONNECTOR_ID, display_name: "Search Fan-in Test Connector", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, protocol_version: "0.1.0", streams: [ { @@ -86,6 +87,8 @@ const baseManifest = { required: ["id", "subject", "received_at"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, { consent_time_field: "received_at", @@ -104,6 +107,8 @@ const baseManifest = { required: ["id", "subject", "received_at"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -339,15 +344,11 @@ test("owner-mode lexical fan-in: deprecated connector_instance_id alias narrows function makeClientWiring( query: Record<string, unknown>, - { grantStreamConnectionId = null }: { grantStreamConnectionId?: string | null } = {} + { authorizedInstanceIds = [INSTANCE_A, INSTANCE_B] }: { authorizedInstanceIds?: string[] } = {} ): SearchRunArgs { const grant = { source: { id: CONNECTOR_ID, kind: "connector" }, - streams: [ - grantStreamConnectionId - ? { connection_id: grantStreamConnectionId, fields: ["id", "subject", "received_at"], name: STREAM } - : { fields: ["id", "subject", "received_at"], name: STREAM }, - ], + streams: [{ fields: ["id", "subject", "received_at"], instance_ids: authorizedInstanceIds, name: STREAM }], }; const tokenInfo = { client_id: "cl_test", @@ -394,7 +395,7 @@ function makeClientWiring( }; } -test("client-mode lexical fan-in: hits union across grant-authorized bindings (no per-stream pin)", async () => { +test("client-mode lexical fan-in: hits union across explicitly grant-authorized instances", async () => { await withDualBindingDb(async () => { const wiring = makeClientWiring({ q: "overdraft" }); const { envelope } = await runLexicalSearch(wiring); @@ -406,9 +407,9 @@ test("client-mode lexical fan-in: hits union across grant-authorized bindings (n }); }); -test("client-mode lexical fan-in: per-stream grant connection_id pins the search to one binding", async () => { +test("client-mode lexical fan-in: per-stream grant instance_ids constrain search to one binding", async () => { await withDualBindingDb(async () => { - const wiring = makeClientWiring({ q: "overdraft" }, { grantStreamConnectionId: INSTANCE_A }); + const wiring = makeClientWiring({ q: "overdraft" }, { authorizedInstanceIds: [INSTANCE_A] }); const { envelope } = await runLexicalSearch(wiring); const results = searchResults(envelope.data); assert.equal(results.length, 1); @@ -416,13 +417,13 @@ test("client-mode lexical fan-in: per-stream grant connection_id pins the search }); }); -test("client-mode lexical fan-in: mixed per-stream grant connection_id constraints are honored independently", async () => { +test("client-mode lexical fan-in: mixed per-stream grant instance_ids are honored independently", async () => { await withDualBindingDb(async () => { const grant = { source: { id: CONNECTOR_ID, kind: "connector" }, streams: [ - { connection_id: INSTANCE_A, fields: ["id", "subject", "received_at"], name: STREAM }, - { connection_id: INSTANCE_B, fields: ["id", "subject", "received_at"], name: ALERTS_STREAM }, + { fields: ["id", "subject", "received_at"], instance_ids: [INSTANCE_A], name: STREAM }, + { fields: ["id", "subject", "received_at"], instance_ids: [INSTANCE_B], name: ALERTS_STREAM }, ], }; const tokenInfo = { @@ -449,11 +450,11 @@ test("client-mode lexical fan-in: mixed per-stream grant connection_id constrain }); }); -test("client-mode lexical fan-in: request connection_id outside grant returns connection_not_found", async () => { +test("client-mode lexical fan-in: active request connection_id outside grant returns connection_not_found", async () => { await withDualBindingDb(async () => { const wiring = makeClientWiring( - { connection_id: "cin_does_not_exist", q: "overdraft" }, - { grantStreamConnectionId: INSTANCE_A } + { connection_id: INSTANCE_B, q: "overdraft" }, + { authorizedInstanceIds: [INSTANCE_A] } ); await assert.rejects( () => runLexicalSearch(wiring), @@ -465,7 +466,7 @@ test("client-mode lexical fan-in: request connection_id outside grant returns co }); }); -test("semantic plan builder honors mixed per-stream grant connection_id constraints per binding", () => { +test("semantic plan builder honors mixed per-stream grant instance_ids per binding", () => { const manifest = { streams: [ { name: STREAM, query: { search: { semantic_fields: ["subject"] } } }, @@ -474,8 +475,8 @@ test("semantic plan builder honors mixed per-stream grant connection_id constrai }; const grant = { streams: [ - { connection_id: INSTANCE_A, fields: ["subject"], name: STREAM }, - { connection_id: INSTANCE_B, fields: ["subject"], name: ALERTS_STREAM }, + { fields: ["subject"], instance_ids: [INSTANCE_A], name: STREAM }, + { fields: ["subject"], instance_ids: [INSTANCE_B], name: ALERTS_STREAM }, ], }; const planA = buildSemanticSearchPlanForGrant({ diff --git a/reference-implementation/test/search-temporal-authorization.test.ts b/reference-implementation/test/search-temporal-authorization.test.ts new file mode 100644 index 000000000..4729b5fb1 --- /dev/null +++ b/reference-implementation/test/search-temporal-authorization.test.ts @@ -0,0 +1,41 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { __filterLexicalCandidateRecordKeysForTest } from "../server/search.ts"; + +test("lexical candidate scan applies the frozen grant time field before ranking", () => { + const rows = [ + { + record_json: JSON.stringify({ mutable_time: "1999-01-01T00:00:00Z", occurred_at: "2026-01-01T00:00:00Z" }), + record_key: "since-inclusive", + }, + { + record_json: JSON.stringify({ mutable_time: "2026-01-02T00:00:00Z", occurred_at: "2026-01-02T00:00:00Z" }), + record_key: "inside", + }, + { + record_json: JSON.stringify({ mutable_time: "2026-01-02T00:00:00Z", occurred_at: "2026-01-03T00:00:00Z" }), + record_key: "until-exclusive", + }, + { record_json: JSON.stringify({ mutable_time: "2026-01-02T00:00:00Z" }), record_key: "missing" }, + { record_json: JSON.stringify({ occurred_at: "not-a-time" }), record_key: "malformed" }, + ]; + + const allowed = __filterLexicalCandidateRecordKeysForTest( + rows, + { + name: "events", + time_constraint: { + field: "occurred_at", + since: "2026-01-01T00:00:00Z", + until: "2026-01-03T00:00:00Z", + }, + }, + { consent_time_field: "mutable_time", name: "events" } + ); + + assert.deepEqual(allowed, ["since-inclusive", "inside"]); +}); diff --git a/reference-implementation/test/security-auth-surfaces.test.ts b/reference-implementation/test/security-auth-surfaces.test.ts index 035822c88..48940e169 100644 --- a/reference-implementation/test/security-auth-surfaces.test.ts +++ b/reference-implementation/test/security-auth-surfaces.test.ts @@ -17,10 +17,16 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import type { RefSpineEventsPageEnvelope } from "../operations/ref-spine-events-page/index.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; +import { introspectionHeaders } from "./helpers/introspection.ts"; +import { TEST_RS_INTROSPECTION_CREDENTIALS } from "./helpers/introspection-test-credentials.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); +const OWNER_SUBJECT_ID = "owner_local"; +const NOW = "2026-05-31T00:00:00.000Z"; // `startServer`'s inferred asServer/rsServer type comes from a framework // `.listen()` call whose TS overload resolves to an http2-shaped type, but at @@ -156,7 +162,7 @@ async function approveSpotifyGrant( ], client_id: "concert_recommendation_app", }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); if (initResp.status !== 201) { @@ -164,13 +170,32 @@ async function approveSpotifyGrant( throw new Error(`PAR failed (${initResp.status}): ${errBody}`); } const initiate = (await initResp.json()) as ParInitiateBody; - const approveResp = await fetch(`${asUrl}/consent/approve`, { + const reviewResp = await fetch(`${asUrl}/consent/review`, { body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: subjectId }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); - assert.equal(approveResp.status, 200); - return approveResp.json() as Promise<ApproveGrantResponse>; + const reviewText = await reviewResp.text(); + assert.equal(reviewResp.status, 200, reviewText); + const review = JSON.parse(reviewText) as { + approval_review: object; + approval_review_revision: string; + request_uri: string; + }; + assert.ok(review.approval_review); + assert.ok(review.approval_review_revision); + assert.equal(review.request_uri, initiate.request_uri); + const approveResp = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const approveBody = await approveResp.text(); + assert.equal(approveResp.status, 200, approveBody); + return JSON.parse(approveBody) as ApproveGrantResponse; } interface HarnessContext { @@ -183,7 +208,14 @@ async function withHarness(fn: (ctx: HarnessContext) => Promise<void>): Promise< const spotifyManifest = JSON.parse( readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8") ) as SpotifyManifest; - const server = (await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 })) as TestServer; + const server = (await startServer({ + asPort: 0, + dbPath: ":memory:", + introspectionCallerCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + quiet: true, + rsIntrospectionCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + rsPort: 0, + })) as TestServer; const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; try { @@ -193,12 +225,30 @@ async function withHarness(fn: (ctx: HarnessContext) => Promise<void>): Promise< method: "POST", }); assert.equal(registerResp.status, 201); + await seedSpotifyInstance(spotifyManifest); await fn({ asUrl, rsUrl, spotifyManifest }); } finally { await closeServer(server); } } +async function seedSpotifyInstance(spotifyManifest: SpotifyManifest): Promise<void> { + const connectorId = canonicalConnectorKey(spotifyManifest.connector_id); + assert.ok(connectorId, "spotify manifest must resolve to a canonical connector key"); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: "cin_security_auth_surfaces_spotify", + createdAt: NOW, + displayName: "Security Auth Surfaces Spotify", + ownerSubjectId: OWNER_SUBJECT_ID, + sourceBinding: { account_hint: "security-auth-surfaces@example.com" }, + sourceBindingKey: "security-auth-surfaces@example.com", + sourceKind: "account", + status: "active", + updatedAt: NOW, + }); +} + test("security: harden reference auth surfaces", async (t) => { await t.test("grant timeline never echoes token_id", async () => { await withHarness(async ({ asUrl, spotifyManifest }) => { @@ -259,7 +309,6 @@ test("security: harden reference auth surfaces", async (t) => { await withHarness(async ({ asUrl, spotifyManifest }) => { const approval = await approveSpotifyGrant(asUrl, spotifyManifest); const resp = await fetch(`${asUrl}/grants/${approval.grant.grant_id}/revoke`, { - headers: { "Content-Type": "application/json" }, method: "POST", }); assert.equal(resp.status, 401); @@ -269,7 +318,7 @@ test("security: harden reference auth surfaces", async (t) => { // The grant SHALL remain unchanged. Use a fresh introspect call to prove it. const introResp = await fetch(`${asUrl}/introspect`, { body: JSON.stringify({ token: approval.token }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); const intro = (await introResp.json()) as IntrospectResponseBody; @@ -313,7 +362,7 @@ test("security: harden reference auth surfaces", async (t) => { // A should still be active. const introResp = await fetch(`${asUrl}/introspect`, { body: JSON.stringify({ token: a.token }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); const intro = (await introResp.json()) as IntrospectResponseBody; diff --git a/reference-implementation/test/security-consent-authorship-classes.test.ts b/reference-implementation/test/security-consent-authorship-classes.test.ts index 708039081..610ee7544 100644 --- a/reference-implementation/test/security-consent-authorship-classes.test.ts +++ b/reference-implementation/test/security-consent-authorship-classes.test.ts @@ -6,22 +6,22 @@ // keeps the three authorship classes visually and semantically distinct, and // that client-authored claims are rendered AS claims, never as protocol facts: // -// • PROTOCOL — facts the owner's server enforces/verifies (access mode, +// - PROTOCOL: facts the owner's server enforces/verifies (access mode, // retention, source binding, resolved client-identity origin). -// • MANIFEST — owner-trusted human descriptions of the requested streams. -// • CLIENT — the client's own claims (self-described app name, the stated -// purpose, and per-stream `client_claims`), each disclaimed as not enforced. +// - MANIFEST: owner-trusted human descriptions of the requested streams. +// - CLIENT: the client's own claims (self-described app name, the stated +// purpose, and top-level `client_claims`), each disclaimed as not enforced. // // Before this fix, the renderer flattened `purpose_code` / `purpose_description` // into the same undifferentiated key/value list as the protocol facts, and -// dropped per-stream `client_claims` entirely — so the rendered HTML did not +// dropped top-level `client_claims` entirely, so the rendered HTML did not // present the three classes as distinct, violating the normative MUST and the // steering principle "keep protocol facts, manifest-authored descriptions, and // client-authored claims visually and semantically distinct." // // Spec: openspec/specs/reference-implementation-architecture/spec.md // (Requirement: "Hosted consent UI SHALL disclose effective access risk" -// — scenario: "Hosted consent distinguishes the three authorship classes") +// - scenario: "Hosted consent distinguishes the three authorship classes") import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; @@ -29,15 +29,19 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); +const OWNER_SUBJECT_ID = "owner_local"; +const NOW = "2026-05-31T00:00:00.000Z"; // `startServer`'s inferred asServer/rsServer type comes from a framework // `.listen()` call whose TS overload resolves to an http2-shaped type, but at // runtime these are plain node:http/https servers (the framework never -// negotiates ALPN in this reference stack) — so `closeAllConnections` (added +// negotiates ALPN in this reference stack), so `closeAllConnections` (added // Node 18.2+) and the single-error-arg `close` callback genuinely exist and // are safe to declare here. Established pattern, see // connector-failure-diagnostics-control-plane.test.ts / connector-gap-severity.test.ts. @@ -99,18 +103,36 @@ async function withHarness( method: "POST", }); assert.equal(registerResp.status, 201); + await seedSpotifyInstance(spotifyManifest); await fn({ asUrl, spotifyManifest }); } finally { await closeServer(server); } } +async function seedSpotifyInstance(spotifyManifest: SpotifyManifest): Promise<void> { + const connectorId = canonicalConnectorKey(spotifyManifest.connector_id); + assert.ok(connectorId, "spotify manifest must resolve to a canonical connector key"); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: "cin_security_consent_authorship_spotify", + createdAt: NOW, + displayName: "Security Consent Authorship Spotify", + ownerSubjectId: OWNER_SUBJECT_ID, + sourceBinding: { account_hint: "security-consent-authorship@example.com" }, + sourceBindingKey: "security-consent-authorship@example.com", + sourceKind: "account", + status: "active", + updatedAt: NOW, + }); +} + // Client-authored values we expect to be rendered AS claims, distinct from // protocol facts. Deliberately chosen to be unambiguous string needles. const CLIENT_PURPOSE = "Recommend concerts based on your listening history"; -const CLIENT_CLAIM_PURPOSE = "We only read your top artists to find nearby shows"; const CLIENT_CLAIM_COMMITMENT_A = "We never sell your data"; const CLIENT_CLAIM_COMMITMENT_B = "We delete reads after 30 days"; +const PER_STREAM_CLIENT_CLAIMS_ERROR_RE = /streams\/0|additional properties|client_claims/i; async function initiate( asUrl: string, @@ -121,19 +143,13 @@ async function initiate( authorization_details: [ { access_mode: "continuous", + client_claims: { + commitments: [CLIENT_CLAIM_COMMITMENT_A, CLIENT_CLAIM_COMMITMENT_B], + }, purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: CLIENT_PURPOSE, source: { id: spotifyManifest.connector_id, kind: "connector" }, - streams: [ - { - client_claims: { - commitments: [CLIENT_CLAIM_COMMITMENT_A, CLIENT_CLAIM_COMMITMENT_B], - purpose: CLIENT_CLAIM_PURPOSE, - }, - name: "top_artists", - view: "basic", - }, - ], + streams: [{ name: "top_artists" }], type: "https://pdpp.dev/data-access", ...overrides, }, @@ -193,7 +209,7 @@ function authorshipBlock(html: string, authorship: string): string { test("security: hosted consent renders the three authorship classes distinctly", async (t) => { await t.test( - "client_claims + purpose render as client-authored claims, distinct from protocol facts and manifest streams", + "top-level client_claims + purpose render as client-authored claims, distinct from protocol facts and manifest streams", async () => { await withHarness(async ({ asUrl, spotifyManifest }: { asUrl: string; spotifyManifest: SpotifyManifest }) => { const par = await initiate(asUrl, spotifyManifest); @@ -201,7 +217,7 @@ test("security: hosted consent renders the three authorship classes distinctly", assert.equal(consentResp.status, 200); const html = await consentResp.text(); - // ── All three authorship classes are present and marked distinctly ── + // All three authorship classes are present and marked distinctly. for (const authorship of ["protocol", "manifest", "client"]) { assert.ok( html.includes(`data-authorship="${authorship}"`), @@ -216,24 +232,15 @@ test("security: hosted consent renders the three authorship classes distinctly", assert.ok(protocolBlock, "protocol authorship block SHALL be present"); assert.ok(manifestBlock, "manifest authorship block SHALL be present"); - // ── client_claims (previously DROPPED) are now rendered ── - assert.ok( - html.includes(CLIENT_CLAIM_PURPOSE), - "consent HTML SHALL render the per-stream client_claims purpose" - ); + // Top-level client_claims are rendered. assert.ok( html.includes(CLIENT_CLAIM_COMMITMENT_A) && html.includes(CLIENT_CLAIM_COMMITMENT_B), - "consent HTML SHALL render the per-stream client_claims commitments" + "consent HTML SHALL render top-level client_claims commitments" ); - // ── client-authored values appear ONLY inside the client block, never - // inside the protocol block (they must not be presented as facts) ── - for (const claim of [ - CLIENT_PURPOSE, - CLIENT_CLAIM_PURPOSE, - CLIENT_CLAIM_COMMITMENT_A, - CLIENT_CLAIM_COMMITMENT_B, - ]) { + // Client-authored values appear ONLY inside the client block, never + // inside the protocol block (they must not be presented as facts). + for (const claim of [CLIENT_PURPOSE, CLIENT_CLAIM_COMMITMENT_A, CLIENT_CLAIM_COMMITMENT_B]) { assert.ok( clientBlock.includes(claim), `client-authored value "${claim}" SHALL be rendered inside the client authorship block` @@ -244,20 +251,20 @@ test("security: hosted consent renders the three authorship classes distinctly", ); } - // ── client claims carry an explicit "not enforced" disclaimer ── + // Client claims carry an explicit "not enforced" disclaimer. assert.ok( clientBlock.toLowerCase().includes("not enforced by your server"), "the client_claims block SHALL disclaim that the claims are not enforced by the server" ); - // ── protocol facts (access mode is server-enforced) live in the - // protocol block, not the client block ── + // Protocol facts (access mode is server-enforced) live in the protocol + // block, not the client block. assert.ok( protocolBlock.toLowerCase().includes("continuous"), "the enforced access mode SHALL be rendered as a protocol fact" ); - // ── manifest-authored stream names live in the manifest block ── + // Manifest-authored stream names live in the manifest block. assert.ok( manifestBlock.includes('<span class="hosted-ui-stream-name">top_artists</span>'), "the requested stream name SHALL be rendered in the manifest authorship block" @@ -271,8 +278,9 @@ test("security: hosted consent renders the three authorship classes distinctly", async () => { await withHarness(async ({ asUrl, spotifyManifest }: { asUrl: string; spotifyManifest: SpotifyManifest }) => { const par = await initiate(asUrl, spotifyManifest, { - // No client_claims on the stream this time. - streams: [{ name: "top_artists", view: "basic" }], + client_claims: undefined, + // No client_claims in this request. + streams: [{ name: "top_artists" }], }); const consentResp = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(par.request_uri)}`); assert.equal(consentResp.status, 200); @@ -298,4 +306,38 @@ test("security: hosted consent renders the three authorship classes distinctly", }); } ); + + await t.test("per-stream client_claims fail closed during request validation", async () => { + await withHarness(async ({ asUrl, spotifyManifest }: { asUrl: string; spotifyManifest: SpotifyManifest }) => { + const resp = await fetch(`${asUrl}/oauth/par`, { + body: JSON.stringify({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + purpose_description: CLIENT_PURPOSE, + source: { id: spotifyManifest.connector_id, kind: "connector" }, + streams: [ + { + client_claims: { + commitments: [CLIENT_CLAIM_COMMITMENT_A], + }, + name: "top_artists", + }, + ], + type: "https://pdpp.dev/data-access", + }, + ], + client_display: { name: "Concert Recommender" }, + client_id: "concert_recommendation_app", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(resp.status, 400); + const body = (await resp.json()) as { error?: { code?: string; message?: string } }; + assert.equal(body.error?.code, "invalid_authorization_details"); + assert.match(body.error?.message ?? "", PER_STREAM_CLIENT_CLAIMS_ERROR_RE); + }); + }); }); diff --git a/reference-implementation/test/security-consent-risk-disclosure.test.ts b/reference-implementation/test/security-consent-risk-disclosure.test.ts index e3c7cb467..18228fd73 100644 --- a/reference-implementation/test/security-consent-risk-disclosure.test.ts +++ b/reference-implementation/test/security-consent-risk-disclosure.test.ts @@ -1,5 +1,3 @@ -const TOP_LEVEL_REGEX_1 = /ai_training/i; - // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 @@ -27,14 +25,25 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); +const OWNER_SUBJECT_ID = "owner_local"; +const NOW = "2026-05-31T00:00:00.000Z"; +const AI_TRAINING_ERROR_RE = /ai_training/i; +const AI_TRAINING_FIELD_RE = /name="ai_training_consented"/; +const APPROVE_ACTION_RE = /action="\/consent\/approve"/; +const APPROVAL_REVIEW_REVISION_FIELD_RE = /name="approval_review_revision"/; +const REVIEW_ACTION_RE = /action="\/consent\/review"/; +const REVIEW_ERROR_RE = /review|ai_training|consent/i; +const REVIEW_REVISION_FIELD_RE = /name="approval_review_revision" value="([^"]+)"/; // `startServer`'s inferred asServer/rsServer type comes from a framework // `.listen()` call whose TS overload resolves to an http2-shaped type, but at -// runtime these are plain node:http/https servers — so `closeAllConnections` +// runtime these are plain node:http/https servers, so `closeAllConnections` // (added Node 18.2+) and the single-error-arg `close` callback genuinely // exist and are safe to declare here. Established pattern, see // connector-instance-admission-routes.test.ts. @@ -48,10 +57,6 @@ interface SpotifyManifest { streams: { name: string }[]; } -interface ErrorBody { - error?: { code?: string; message?: string }; -} - interface ParResponse { request_uri: string; } @@ -79,20 +84,6 @@ async function closeServer(server: TestServer): Promise<void> { await Promise.allSettled([closeOne(server.asServer), closeOne(server.rsServer)]); } -async function fetchJson<T = ErrorBody>( - url: string, - opts: RequestInit = {} -): Promise<{ status: number; body: T | null }> { - const resp = await fetch(url, opts); - let body: T | null = null; - try { - body = (await resp.json()) as T; - } catch { - /* non-json */ - } - return { body, status: resp.status }; -} - async function withHarness( fn: (ctx: { asUrl: string; spotifyManifest: SpotifyManifest }) => Promise<void> ): Promise<void> { @@ -108,12 +99,30 @@ async function withHarness( method: "POST", }); assert.equal(registerResp.status, 201); + await seedSpotifyInstance(spotifyManifest); await fn({ asUrl, spotifyManifest }); } finally { await closeServer(server); } } +async function seedSpotifyInstance(spotifyManifest: SpotifyManifest): Promise<void> { + const connectorId = canonicalConnectorKey(spotifyManifest.connector_id); + assert.ok(connectorId, "spotify manifest must resolve to a canonical connector key"); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: "cin_security_consent_risk_spotify", + createdAt: NOW, + displayName: "Security Consent Risk Spotify", + ownerSubjectId: OWNER_SUBJECT_ID, + sourceBinding: { account_hint: "security-consent-risk@example.com" }, + sourceBindingKey: "security-consent-risk@example.com", + sourceKind: "account", + status: "active", + updatedAt: NOW, + }); +} + async function initiate( asUrl: string, spotifyManifest: SpotifyManifest, @@ -126,7 +135,7 @@ async function initiate( purpose_code: "https://pdpp.dev/purpose/personalization", purpose_description: "Consent risk disclosure regression", source: { id: spotifyManifest.connector_id, kind: "connector" }, - streams: [{ name: "top_artists", view: "basic" }], + streams: [{ name: "top_artists" }], type: "https://pdpp.dev/data-access", ...overrides, }, @@ -190,8 +199,8 @@ test("security: consent-risk disclosure invariants", async (t) => { await withHarness(async ({ asUrl, spotifyManifest }) => { const par = await initiate(asUrl, spotifyManifest, { access_mode: "continuous", - // No retention block — this is the no-expiry case. - streams: [{ name: "top_artists", view: "basic" }], + // No retention block: this is the no-expiry case. + streams: [{ name: "top_artists" }], }); const consentResp = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(par.request_uri)}`); assert.equal(consentResp.status, 200); @@ -215,24 +224,22 @@ test("security: consent-risk disclosure invariants", async (t) => { } ); - await t.test("ai_training request without affirmative consent fails with a typed PDPP error envelope", async () => { + await t.test("ai_training review without affirmative consent fails with a typed PDPP error envelope", async () => { await withHarness(async ({ asUrl, spotifyManifest }) => { const par = await initiate(asUrl, spotifyManifest, { access_mode: "continuous", purpose_code: "https://pdpp.dev/purpose/ai_training", purpose_description: "Training a recommendation model", - streams: [{ name: "top_artists", view: "basic" }], + streams: [{ name: "top_artists" }], }); - const resp = await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ - request_uri: par.request_uri, - subject_id: "owner_local", - // Deliberately omit ai_training_consented. - }), - headers: { "Content-Type": "application/json" }, + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: par.request_uri, subject_id: "owner_local" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); + const reviewText = await reviewResp.text(); + const resp = { body: JSON.parse(reviewText), status: reviewResp.status }; assert.notEqual(resp.status, 500, "response SHALL NOT be a generic 500"); assert.ok(resp.status >= 400 && resp.status < 500, `expected 4xx, got ${resp.status}`); @@ -248,11 +255,80 @@ test("security: consent-risk disclosure invariants", async (t) => { if (!errorMessage) { throw new Error("expected resp.body.error.message to be set"); } - assert.match( - errorMessage, - TOP_LEVEL_REGEX_1, - "PDPP error message SHALL identify the ai_training consent requirement" - ); + assert.match(errorMessage, REVIEW_ERROR_RE, "PDPP error message SHALL identify the rejected consent review"); + }); + }); + + await t.test("ai_training review with explicit false affirmation fails at policy boundary", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const par = await initiate(asUrl, spotifyManifest, { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/ai_training", + purpose_description: "Training a recommendation model", + streams: [{ name: "top_artists" }], + }); + + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ + ai_training_consented: false, + request_uri: par.request_uri, + subject_id: "owner_local", + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewText = await reviewResp.text(); + const resp = { body: JSON.parse(reviewText), status: reviewResp.status }; + + assert.notEqual(resp.status, 500, "response SHALL NOT be a generic 500"); + assert.ok(resp.status >= 400 && resp.status < 500, `expected 4xx, got ${resp.status}`); + assert.equal(typeof resp.body?.error?.code, "string", "PDPP error envelope SHALL carry an error.code"); + assert.equal(typeof resp.body?.error?.message, "string"); + assert.match(String(resp.body?.error?.message), AI_TRAINING_ERROR_RE); + }); + }); + + await t.test("ai_training HTML flow finalizes affirmation at review and final approve has no AI field", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const par = await initiate(asUrl, spotifyManifest, { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/ai_training", + purpose_description: "Training a recommendation model", + streams: [{ name: "top_artists" }], + }); + + const initial = await fetch(`${asUrl}/consent?request_uri=${encodeURIComponent(par.request_uri)}`); + const initialHtml = await initial.text(); + assert.equal(initial.status, 200, initialHtml); + assert.match(initialHtml, AI_TRAINING_FIELD_RE); + assert.match(initialHtml, REVIEW_ACTION_RE); + assert.doesNotMatch(initialHtml, APPROVAL_REVIEW_REVISION_FIELD_RE); + + const review = await fetch(`${asUrl}/consent/review`, { + body: new URLSearchParams({ + ai_training_consented: "1", + request_uri: par.request_uri, + subject_id: "owner_local", + }).toString(), + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + const reviewHtml = await review.text(); + assert.equal(review.status, 200, reviewHtml); + const revisionMatch = REVIEW_REVISION_FIELD_RE.exec(reviewHtml); + assert.ok(revisionMatch?.[1], "reviewed HTML must carry approval_review_revision"); + assert.doesNotMatch(reviewHtml, AI_TRAINING_FIELD_RE); + assert.match(reviewHtml, APPROVE_ACTION_RE); + + const approved = await fetch(`${asUrl}/consent/approve`, { + body: new URLSearchParams({ + approval_review_revision: revisionMatch[1], + request_uri: par.request_uri, + }).toString(), + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(approved.status, 200, await approved.text()); }); }); }); diff --git a/reference-implementation/test/security-consent-token-handoff.test.ts b/reference-implementation/test/security-consent-token-handoff.test.ts index 440a657d7..3fa8573e3 100644 --- a/reference-implementation/test/security-consent-token-handoff.test.ts +++ b/reference-implementation/test/security-consent-token-handoff.test.ts @@ -1,21 +1,25 @@ const TOP_LEVEL_REGEX_1 = /cex_[0-9a-f]{64}/; const TOP_LEVEL_REGEX_2 = /cex_[0-9a-f]{64}/; +const APPROVAL_REVIEW_REVISION_PATTERN = /name="approval_review_revision" value="([^"]+)"/; +const GRANT_ID_RE = /grt_[a-zA-Z0-9]+/; +const FORCED_ORDINARY_DENIAL_ROLLBACK_RE = /forced ordinary denial rollback/; // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; // Regression tests for the harden-consent-token-handoff change. // // Pins the invariants: // 1. The HTML branch of POST /consent/approve never embeds the bearer. // 2. The HTML branch DOES embed an opaque cex_… exchange code. -// 3. POST /consent/exchange redeems the code once and returns +// 3. POST /consent/exchange redeems the code and returns // { grant_id, token, grant }. -// 4. A second redemption attempt fails with a 4xx PDPP error envelope and -// does not leak the bearer. +// 4. A proofless HTML code is single-use, while a matching out-of-band +// recovery proof can recover the same result after response loss. // 5. An expired code fails with a 4xx PDPP error envelope. // 6. An unknown code fails with a 4xx PDPP error envelope. // 7. The JSON branch of POST /consent/approve still returns the bearer in @@ -25,11 +29,28 @@ import { dirname, join } from "node:path"; // reference-implementation-architecture/spec.md import test from "node:test"; import { fileURLToPath } from "node:url"; -import { consumeConsentExchangeCode, createConsentExchangeCode } from "../server/auth.ts"; +import { + type AuthorizationDecisionFaultHook, + approveGrant, + consumeConsentExchangeCode, + createConsentExchangeCode, + denyGrant, + getPendingConsent, + introspect, + parsePendingConsentRequestUri, + revokeGrant, +} from "../server/auth.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; +import { closeDb, getDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; +import { introspectionHeaders } from "./helpers/introspection.ts"; +import { TEST_RS_INTROSPECTION_CREDENTIALS } from "./helpers/introspection-test-credentials.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); +const OWNER_SUBJECT_ID = "owner_local"; +const NOW = "2026-05-31T00:00:00.000Z"; interface TestHttpServer { close: (callback: () => void) => void; @@ -136,16 +157,80 @@ async function initiateGrantRequest(asUrl: string, spotifyManifest: SpotifyManif return initResp.json() as Promise<InitiateGrantResponse>; } +async function reviewConsent(asUrl: string, requestUri: string): Promise<string> { + const response = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: OWNER_SUBJECT_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(response.status, 200, await response.clone().text()); + const body = (await response.json()) as { approval_review_revision?: unknown }; + assert.equal(typeof body.approval_review_revision, "string"); + return body.approval_review_revision as string; +} + +async function approveReviewedHtml(asUrl: string, requestUri: string): Promise<Response> { + const revision = await reviewConsent(asUrl, requestUri); + return fetch(`${asUrl}/consent/approve`, { + body: new URLSearchParams({ approval_review_revision: revision, request_uri: requestUri }).toString(), + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); +} + +async function approveReviewedJson(asUrl: string, requestUri: string): Promise<Response> { + const revision = await reviewConsent(asUrl, requestUri); + return fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ approval_review_revision: revision, request_uri: requestUri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); +} + interface HarnessContext { asUrl: string; spotifyManifest: SpotifyManifest; } +function createDecisionPause(): { paused: Promise<void>; release: () => void; hook: () => Promise<void> } { + let release: () => void = () => undefined; + let markPaused: () => void = () => undefined; + const paused = new Promise<void>((resolve) => { + markPaused = resolve; + }); + const resumed = new Promise<void>((resolve) => { + release = resolve; + }); + return { + hook: async () => { + markPaused(); + await resumed; + }, + paused, + release, + }; +} + +function countConsentEvents(deviceCode: string, eventType: string): number { + const row = getDb() + .prepare( + "SELECT COUNT(*) AS count FROM spine_events WHERE object_id = ? AND object_type = 'pending_consent' AND event_type = ?" + ) + .get(deviceCode, eventType) as { count: number }; + return row.count; +} + async function withHarness(fn: (ctx: HarnessContext) => Promise<void>): Promise<void> { const spotifyManifest = JSON.parse( readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8") ) as SpotifyManifest; - const server = await startServer({ asPort: 0, dbPath: ":memory:", quiet: true, rsPort: 0 }); + const server = await startServer({ + asPort: 0, + dbPath: ":memory:", + introspectionCallerCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + quiet: true, + rsPort: 0, + }); const asUrl = `http://localhost:${server.asPort}`; try { const registerResp = await fetch(`${asUrl}/connectors`, { @@ -154,24 +239,55 @@ async function withHarness(fn: (ctx: HarnessContext) => Promise<void>): Promise< method: "POST", }); assert.equal(registerResp.status, 201); + await seedSpotifyInstance(spotifyManifest); await fn({ asUrl, spotifyManifest }); } finally { await closeServer(server); } } +async function seedSpotifyInstance(spotifyManifest: SpotifyManifest): Promise<void> { + const connectorId = canonicalConnectorKey(spotifyManifest.connector_id); + assert.ok(connectorId, "spotify manifest must resolve to a canonical connector key"); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: "cin_security_consent_handoff_spotify", + createdAt: NOW, + displayName: "Security Consent Handoff Spotify", + ownerSubjectId: OWNER_SUBJECT_ID, + sourceBinding: { account_hint: "security-consent-handoff@example.com" }, + sourceBindingKey: "security-consent-handoff@example.com", + sourceKind: "account", + status: "active", + updatedAt: NOW, + }); +} + test("security: harden consent token handoff", async (t) => { await t.test("HTML approve does not embed the bearer; JSON approve still returns it", async () => { await withHarness(async ({ asUrl, spotifyManifest }) => { // First, get a token via the JSON branch (this is the established // programmatic contract used by the dashboard and every test). const initiateForJson = await initiateGrantRequest(asUrl, spotifyManifest); - const jsonResp = await fetch(`${asUrl}/consent/approve`, { + const reviewForJson = await fetch(`${asUrl}/consent/review`, { body: JSON.stringify({ request_uri: initiateForJson.request_uri, subject_id: "owner_local" }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const jsonReviewText = await reviewForJson.text(); + assert.equal(reviewForJson.status, 200, jsonReviewText); + const jsonReview = JSON.parse(jsonReviewText) as { approval_review_revision: string }; + const jsonResp = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: jsonReview.approval_review_revision, + request_uri: initiateForJson.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); assert.equal(jsonResp.status, 200); + assert.equal(jsonResp.headers.get("cache-control"), "no-store"); + assert.equal(jsonResp.headers.get("pragma"), "no-cache"); const jsonBody = (await jsonResp.json()) as ApproveJsonResponse; assert.equal(typeof jsonBody.token, "string", "JSON branch SHALL still return the bearer"); assert.ok(jsonBody.token.length > 0); @@ -181,10 +297,19 @@ test("security: harden consent token handoff", async (t) => { // Now drive a fresh approval through the HTML branch. const initiateForHtml = await initiateGrantRequest(asUrl, spotifyManifest); + const reviewForHtml = await fetch(`${asUrl}/consent/review`, { + body: new URLSearchParams({ request_uri: initiateForHtml.request_uri, subject_id: "owner_local" }).toString(), + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + const htmlReview = await reviewForHtml.text(); + assert.equal(reviewForHtml.status, 200, htmlReview); + const revisionMatch = htmlReview.match(APPROVAL_REVIEW_REVISION_PATTERN); + assert.ok(revisionMatch?.[1], "review HTML SHALL carry the approval review revision"); const htmlResp = await fetch(`${asUrl}/consent/approve`, { body: new URLSearchParams({ + approval_review_revision: revisionMatch[1], request_uri: initiateForHtml.request_uri, - subject_id: "owner_local", }).toString(), headers: { // Negotiate HTML explicitly; the route uses @@ -197,6 +322,8 @@ test("security: harden consent token handoff", async (t) => { }); assert.equal(htmlResp.status, 200); const htmlText = await htmlResp.text(); + assert.equal(htmlResp.headers.get("cache-control"), "no-store"); + assert.equal(htmlResp.headers.get("pragma"), "no-cache"); assert.ok(htmlText.includes("<html"), "HTML branch SHALL render an HTML document"); // The bearer minted for the JSON approval is unrelated to this approval, // but the bearer minted for THIS approval must not appear anywhere. @@ -213,10 +340,12 @@ test("security: harden consent token handoff", async (t) => { // Redeem and confirm we got a bearer. const exchangeResp = await fetch(`${asUrl}/consent/exchange`, { body: JSON.stringify({ code }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); assert.equal(exchangeResp.status, 200); + assert.equal(exchangeResp.headers.get("cache-control"), "no-store"); + assert.equal(exchangeResp.headers.get("pragma"), "no-cache"); const exchangeBody = (await exchangeResp.json()) as ExchangeResponse; assert.equal(typeof exchangeBody.token, "string"); assert.ok(exchangeBody.token.length > 0); @@ -238,7 +367,7 @@ test("security: harden consent token handoff", async (t) => { // The redeemed bearer SHALL introspect as active for the same grant. const introResp = await fetch(`${asUrl}/introspect`, { body: JSON.stringify({ token: exchangeBody.token }), - headers: { "Content-Type": "application/json" }, + headers: introspectionHeaders(), method: "POST", }); const intro = (await introResp.json()) as IntrospectResponse; @@ -247,13 +376,22 @@ test("security: harden consent token handoff", async (t) => { }); }); - await t.test("a consumed exchange code cannot be redeemed again", async () => { + await t.test("manual HTML exchange is single-use without exposing recovery proof", async () => { await withHarness(async ({ asUrl, spotifyManifest }) => { const initiate = await initiateGrantRequest(asUrl, spotifyManifest); + const review = await fetch(`${asUrl}/consent/review`, { + body: new URLSearchParams({ request_uri: initiate.request_uri, subject_id: "owner_local" }).toString(), + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + const reviewHtml = await review.text(); + assert.equal(review.status, 200, reviewHtml); + const revisionMatch = reviewHtml.match(APPROVAL_REVIEW_REVISION_PATTERN); + assert.ok(revisionMatch?.[1], "review HTML SHALL carry the approval review revision"); const htmlResp = await fetch(`${asUrl}/consent/approve`, { body: new URLSearchParams({ + approval_review_revision: revisionMatch[1], request_uri: initiate.request_uri, - subject_id: "owner_local", }).toString(), headers: { Accept: "text/html", @@ -277,15 +415,122 @@ test("security: harden consent token handoff", async (t) => { const firstBody = (await first.json()) as ExchangeResponse; assert.ok(firstBody.token.length > 0); - // Second redemption fails; bearer SHALL NOT appear in the failure body. - const second = await fetchJson(`${asUrl}/consent/exchange`, { + const second = await fetch(`${asUrl}/consent/exchange`, { + body: JSON.stringify({ code }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(second.status, 410); + assert.equal((await second.text()).includes(firstBody.token), false); + }); + }); + + await t.test("response-loss retry with the same out-of-band proof returns the same durable result", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const initiate = await initiateGrantRequest(asUrl, spotifyManifest); + const response = await approveReviewedJson(asUrl, initiate.request_uri); + assert.equal(response.status, 200); + const approved = (await response.json()) as ApproveJsonResponse; + const proof = "same-proof-bound-to-intended-client"; + const code = await createConsentExchangeCode({ + grant: approved.grant as Record<string, unknown>, + grantId: approved.grant_id, + recoveryProof: proof, + token: approved.token, + }); + const first = await fetch(`${asUrl}/consent/exchange`, { + body: JSON.stringify({ code, proof }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(first.status, 200); + const firstBody = (await first.json()) as ExchangeResponse; + const retry = await fetch(`${asUrl}/consent/exchange`, { + body: JSON.stringify({ code, proof }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(retry.status, 200); + assert.deepEqual((await retry.json()) as ExchangeResponse, firstBody); + const wrongProof = await fetch(`${asUrl}/consent/exchange`, { + body: JSON.stringify({ code, proof: "wrong-proof" }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(wrongProof.status, 410); + }); + }); + + await t.test("concurrent SQLite redemptions converge on one stored transition", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const initiate = await initiateGrantRequest(asUrl, spotifyManifest); + const approval = await approveReviewedHtml(asUrl, initiate.request_uri); + const code = (await approval.text()).match(TOP_LEVEL_REGEX_1)?.[0]; + assert.ok(code); + const responses = await Promise.all( + Array.from({ length: 8 }, () => + fetch(`${asUrl}/consent/exchange`, { + body: JSON.stringify({ code }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }) + ) + ); + assert.equal(responses.filter((response) => response.status === 200).length, 1); + assert.equal(responses.filter((response) => response.status === 410).length, 7); + const success = responses.find((response) => response.status === 200); + assert.ok(success); + const body = (await success.json()) as ExchangeResponse; + assert.ok(body.token); + const stored = getDb() + .prepare("SELECT COUNT(*) AS n, COUNT(redeemed_at) AS redeemed FROM consent_exchange_codes") + .get() as { n: number; redeemed: number }; + assert.deepEqual(stored, { n: 1, redeemed: 1 }); + }); + }); + + await t.test("reissuing a handoff invalidates older outstanding exchange codes", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const initiate = await initiateGrantRequest(asUrl, spotifyManifest); + const response = await approveReviewedJson(asUrl, initiate.request_uri); + assert.equal(response.status, 200); + const approved = (await response.json()) as ApproveJsonResponse; + const firstCode = await createConsentExchangeCode({ + grant: approved.grant as Record<string, unknown>, + grantId: approved.grant_id, + token: approved.token, + }); + const secondCode = await createConsentExchangeCode({ + grant: approved.grant as Record<string, unknown>, + grantId: approved.grant_id, + token: approved.token, + }); + const first = await consumeConsentExchangeCode(firstCode); + assert.equal(first.ok, false); + assert.equal(first.reason, "expired"); + const second = await consumeConsentExchangeCode(secondCode); + assert.equal(second.ok, true); + assert.equal(second.token, approved.token); + }); + }); + + await t.test("a revoked grant is not delivered by a stored exchange code", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const initiate = await initiateGrantRequest(asUrl, spotifyManifest); + const approval = await approveReviewedHtml(asUrl, initiate.request_uri); + const html = await approval.text(); + const code = html.match(TOP_LEVEL_REGEX_1)?.[0]; + const grantId = html.match(GRANT_ID_RE)?.[0]; + assert.ok(code); + assert.ok(grantId); + await revokeGrant(grantId); + const response = await fetch(`${asUrl}/consent/exchange`, { body: JSON.stringify({ code }), headers: { "Content-Type": "application/json" }, method: "POST", }); - assert.ok(second.status >= 400 && second.status < 500, `expected 4xx, got ${second.status}`); - assert.equal(typeof second.body?.error?.code, "string", "failure SHALL be a PDPP error envelope"); - assert.equal(JSON.stringify(second.body).includes(firstBody.token), false); + assert.equal(response.status, 404); + assert.equal((await response.text()).includes("tok_"), false); }); }); @@ -313,24 +558,226 @@ test("security: harden consent token handoff", async (t) => { }); }); - // Direct unit-level coverage of the in-memory store: TTL expiry. We use the - // exported helpers so we do not need to mock time inside the HTTP route. - await t.test("expired exchange codes are not redeemable", () => { - const fakeGrant = { client: { client_id: "cli_test" }, grant_id: "grt_test" }; - const code = createConsentExchangeCode({ - grant: fakeGrant, - grantId: "grt_test", - token: "tok_for_expiry_test", - ttlMs: 1, // immediate expiry + await t.test("expired exchange codes are not redeemable", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const initiate = await initiateGrantRequest(asUrl, spotifyManifest); + const response = await approveReviewedJson(asUrl, initiate.request_uri); + assert.equal(response.status, 200); + const approved = (await response.json()) as ApproveJsonResponse; + const code = await createConsentExchangeCode({ + grant: approved.grant as Record<string, unknown>, + grantId: approved.grant_id, + token: approved.token, + ttlMs: 1, + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + const result = await consumeConsentExchangeCode(code); + assert.equal(result.ok, false); + assert.equal(result.reason, "expired"); + }); + }); + + await t.test("an already-committed approval can create a fresh HTML handoff", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const initiate = await initiateGrantRequest(asUrl, spotifyManifest); + const deviceCode = parsePendingConsentRequestUri(initiate.request_uri); + assert.ok(deviceCode); + // Inject the failure boundary directly: commit approval, then do not call + // the HTML handoff creator or deliver a response. + const pending = await getPendingConsent(deviceCode, { + finalizeReview: true, + subjectId: OWNER_SUBJECT_ID, + }); + assert.ok(pending); + assert.ok(pending.reviewRevision); + const committed = await approveGrant(deviceCode, OWNER_SUBJECT_ID, { + approval_review_revision: pending.reviewRevision, + }); + + const resumed = await fetch(`${asUrl}/consent/approve`, { + body: new URLSearchParams({ + approval_review_revision: pending.reviewRevision as string, + request_uri: initiate.request_uri, + }).toString(), + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(resumed.status, 200); + const html = await resumed.text(); + const code = html.match(TOP_LEVEL_REGEX_1)?.[0]; + assert.ok(code); + const exchange = await fetch(`${asUrl}/consent/exchange`, { + body: JSON.stringify({ code }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(exchange.status, 200); + const resumedBody = (await exchange.json()) as ExchangeResponse; + assert.equal(resumedBody.grant_id, committed.grant.grant_id); + assert.equal(resumedBody.token, committed.token); + }); + }); + + await t.test("an exchange code survives a SQLite-backed server restart", async () => { + const directory = mkdtempSync(join(tmpdir(), "pdpp-consent-handoff-restart-")); + const dbPath = join(directory, "pdpp.sqlite"); + const spotifyManifest = JSON.parse( + readFileSync(join(REFERENCE_IMPL_DIR, "manifests/spotify.json"), "utf8") + ) as SpotifyManifest; + let first: TestServerHandle | null = null; + let second: TestServerHandle | null = null; + try { + first = await startServer({ + asPort: 0, + dbPath, + introspectionCallerCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + quiet: true, + rsPort: 0, + }); + const firstUrl = `http://localhost:${first.asPort}`; + const registerResp = await fetch(`${firstUrl}/connectors`, { + body: JSON.stringify(spotifyManifest), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(registerResp.status, 201); + await seedSpotifyInstance(spotifyManifest); + const initiate = await initiateGrantRequest(firstUrl, spotifyManifest); + const approval = await approveReviewedHtml(firstUrl, initiate.request_uri); + assert.equal(approval.status, 200); + const code = (await approval.text()).match(TOP_LEVEL_REGEX_1)?.[0]; + assert.ok(code); + + await closeServer(first); + first = null; + closeDb(); + + second = await startServer({ + asPort: 0, + dbPath, + introspectionCallerCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, + quiet: true, + rsPort: 0, + }); + const response = await fetch(`http://localhost:${second.asPort}/consent/exchange`, { + body: JSON.stringify({ code }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(response.status, 200); + const result = (await response.json()) as ExchangeResponse; + assert.ok(result.token); + assert.ok(result.grant_id); + } finally { + if (first) { + await closeServer(first); + } + if (second) { + await closeServer(second); + } + closeDb(); + rmSync(directory, { force: true, recursive: true }); + } + }); + + await t.test("ordinary approval wins a paused denial without contradictory denial evidence", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const initiated = await initiateGrantRequest(asUrl, spotifyManifest); + const deviceCode = parsePendingConsentRequestUri(initiated.request_uri); + assert.ok(deviceCode); + const pending = await getPendingConsent(deviceCode, { + finalizeReview: true, + subjectId: OWNER_SUBJECT_ID, + }); + assert.ok(pending?.reviewRevision); + const pause = createDecisionPause(); + const denial = denyGrant(deviceCode, { beforeCasHook: pause.hook }); + await pause.paused; + const approved = await approveGrant(deviceCode, OWNER_SUBJECT_ID, { + approval_review_revision: pending.reviewRevision, + }); + pause.release(); + + await assert.rejects( + denial, + (err: unknown) => err instanceof Error && "code" in err && err.code === "approval_conflict" + ); + assert.equal((await introspect(approved.token)).active, true); + assert.equal(countConsentEvents(deviceCode, "consent.approved"), 1); + assert.equal(countConsentEvents(deviceCode, "consent.denied"), 0); + }); + }); + + await t.test("ordinary denial is terminal and rolls back its event on transaction failure", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const rollbackInitiated = await initiateGrantRequest(asUrl, spotifyManifest); + const rollbackCode = parsePendingConsentRequestUri(rollbackInitiated.request_uri); + assert.ok(rollbackCode); + const faultHook: AuthorizationDecisionFaultHook = (stage) => { + if (stage === "after_event_before_commit") { + throw new Error("forced ordinary denial rollback"); + } + }; + await assert.rejects(denyGrant(rollbackCode, { faultHook }), FORCED_ORDINARY_DENIAL_ROLLBACK_RE); + assert.ok(await getPendingConsent(rollbackCode), "rolled-back denial remains pending"); + assert.equal(countConsentEvents(rollbackCode, "consent.denied"), 0); + + const deniedInitiated = await initiateGrantRequest(asUrl, spotifyManifest); + const deniedCode = parsePendingConsentRequestUri(deniedInitiated.request_uri); + assert.ok(deniedCode); + const pending = await getPendingConsent(deniedCode, { + finalizeReview: true, + subjectId: OWNER_SUBJECT_ID, + }); + assert.ok(pending?.reviewRevision); + assert.equal(await denyGrant(deniedCode), true); + await assert.rejects( + approveGrant(deniedCode, OWNER_SUBJECT_ID, { approval_review_revision: pending.reviewRevision }), + (err: unknown) => err instanceof Error && "code" in err && err.code === "approval_conflict" + ); + assert.equal(countConsentEvents(deniedCode, "consent.denied"), 1); + assert.equal(countConsentEvents(deniedCode, "consent.approved"), 0); + }); + }); + + await t.test("ordinary mixed approval and denial contention has one terminal outcome", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const initiated = await initiateGrantRequest(asUrl, spotifyManifest); + const deviceCode = parsePendingConsentRequestUri(initiated.request_uri); + assert.ok(deviceCode); + const pending = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: OWNER_SUBJECT_ID }); + assert.ok(pending?.reviewRevision); + const attempts = await Promise.allSettled( + Array.from({ length: 16 }, (_, index) => + index % 2 === 0 + ? approveGrant(deviceCode, OWNER_SUBJECT_ID, { approval_review_revision: pending.reviewRevision }) + : denyGrant(deviceCode) + ) + ); + let approvedToken: string | null = null; + for (const attempt of attempts) { + if ( + attempt.status === "fulfilled" && + typeof attempt.value === "object" && + attempt.value !== null && + "token" in attempt.value && + typeof attempt.value.token === "string" + ) { + approvedToken = attempt.value.token; + break; + } + } + const terminal = (await getDb() + .prepare("SELECT status, token_id FROM pending_consents WHERE device_code = ?") + .get(deviceCode)) as { status: string; token_id: string | null }; + assert.ok(terminal.status === "approved" || terminal.status === "denied"); + assert.equal(terminal.status === "approved", approvedToken !== null); + assert.equal(countConsentEvents(deviceCode, "consent.approved"), terminal.status === "approved" ? 1 : 0); + assert.equal(countConsentEvents(deviceCode, "consent.denied"), terminal.status === "denied" ? 1 : 0); + assert.equal(terminal.status === "denied", terminal.token_id === null); + if (approvedToken) { + assert.equal((await introspect(approvedToken)).active, true); + } }); - // Wait one tick past TTL. - return new Promise((resolve) => - setTimeout(() => { - const result = consumeConsentExchangeCode(code); - assert.equal(result.ok, false); - assert.equal(result.reason, "expired"); - resolve(); - }, 5) - ); }); }); diff --git a/reference-implementation/test/security-device-code-exposure.test.ts b/reference-implementation/test/security-device-code-exposure.test.ts index ccb7bfa9e..7062abfff 100644 --- a/reference-implementation/test/security-device-code-exposure.test.ts +++ b/reference-implementation/test/security-device-code-exposure.test.ts @@ -23,7 +23,10 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; +import { getDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; +import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; const REGEXP_1 = /^urn:pdpp:pending-consent:/; const REGEXP_2 = /^urn:pdpp:pending-consent:/; @@ -31,6 +34,8 @@ const REGEXP_3 = /^urn:pdpp:pending-consent:/; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); +const OWNER_SUBJECT_ID = "owner_local"; +const NOW = "2026-05-31T00:00:00.000Z"; interface TestHttpServer { close: (callback: () => void) => void; @@ -157,12 +162,30 @@ async function withHarness(fn: (ctx: HarnessContext) => Promise<void>): Promise< method: "POST", }); assert.equal(registerResp.status, 201); + await seedSpotifyInstance(spotifyManifest); await fn({ asUrl, spotifyManifest }); } finally { await closeServer(server); } } +async function seedSpotifyInstance(spotifyManifest: SpotifyManifest): Promise<void> { + const connectorId = canonicalConnectorKey(spotifyManifest.connector_id); + assert.ok(connectorId, "spotify manifest must resolve to a canonical connector key"); + await createSqliteConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: "cin_security_device_code_spotify", + createdAt: NOW, + displayName: "Security Device Code Spotify", + ownerSubjectId: OWNER_SUBJECT_ID, + sourceBinding: { account_hint: "security-device-code@example.com" }, + sourceBindingKey: "security-device-code@example.com", + sourceKind: "account", + status: "active", + updatedAt: NOW, + }); +} + async function startConsentPar(asUrl: string, spotifyManifest: SpotifyManifest): Promise<ConsentPar> { const resp = await fetch(`${asUrl}/oauth/par`, { body: JSON.stringify({ @@ -197,6 +220,73 @@ async function startOwnerDeviceFlow(asUrl: string, clientId = "cli_longview"): P return resp.json() as Promise<DeviceAuthorization>; } +test("GET /_ref/approvals/:approval_id projects a live consent without device-flow credentials", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + const consentPar = await startConsentPar(asUrl, spotifyManifest); + const consentDeviceCode = consentPar.request_uri.replace(REGEXP_1, ""); + const { body: rawApprovals } = await fetchJson(`${asUrl}/_ref/approvals`); + const approvals = rawApprovals as ApprovalsList; + const consentEntry = approvals.data.find((entry) => entry.kind === "consent"); + assert.ok(consentEntry, "expected a pending consent approval"); + if (!consentEntry) { + throw new Error("unreachable: assert.ok would have thrown"); + } + + const detailResp = await fetch(`${asUrl}/_ref/approvals/${encodeURIComponent(consentEntry.approval_id)}`); + const detailRaw = await detailResp.text(); + assert.equal(detailResp.status, 200, detailRaw); + const detail = JSON.parse(detailRaw) as { approval_id: string; kind: string; purpose: { description: string } }; + assert.equal(detail.approval_id, consentEntry.approval_id); + assert.equal(detail.kind, "consent"); + assert.equal(detail.purpose.description, "Device-code-exposure regression smoke"); + assert.ok(!detailRaw.includes(consentDeviceCode), "detail leaked device_code"); + assert.ok(!detailRaw.includes(consentPar.request_uri), "detail leaked request_uri"); + assert.ok(!detailRaw.includes("params_json"), "detail leaked raw pending payload"); + + const reviewResp = await fetch(`${asUrl}/consent/review`, { + body: JSON.stringify({ approval_id: consentEntry.approval_id, subject_id: "owner_local" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewText = await reviewResp.text(); + assert.equal(reviewResp.status, 200, reviewText); + const review = JSON.parse(reviewText) as { approval_review_revision: string; request_uri: string }; + assert.equal(review.request_uri, consentPar.request_uri); + + const approveResp = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(approveResp.status, 200); + const terminalResp = await fetch(`${asUrl}/_ref/approvals/${encodeURIComponent(consentEntry.approval_id)}`); + assert.equal(terminalResp.status, 404, "terminal approvals must not render a review projection"); + }); +}); + +test("GET /_ref/approvals/:approval_id does not project expired consent approvals", async () => { + await withHarness(async ({ asUrl, spotifyManifest }) => { + await startConsentPar(asUrl, spotifyManifest); + const { body: rawApprovals } = await fetchJson(`${asUrl}/_ref/approvals`); + const approvals = rawApprovals as ApprovalsList; + const consentEntry = approvals.data.find((entry) => entry.kind === "consent"); + assert.ok(consentEntry, "expected a pending consent approval"); + if (!consentEntry) { + throw new Error("unreachable: assert.ok would have thrown"); + } + + getDb() + .prepare("UPDATE pending_consents SET expires_at = ? WHERE approval_id = ?") + .run("2026-08-11T00:00:00.000Z", consentEntry.approval_id); + + const expiredResp = await fetch(`${asUrl}/_ref/approvals/${encodeURIComponent(consentEntry.approval_id)}`); + assert.equal(expiredResp.status, 404, "expired approvals must not render a review projection"); + }); +}); + test("security: device-code exposure on _ref read surfaces", async (t) => { await t.test("/_ref/approvals never echoes device_code, request_uri, or user_code", async () => { await withHarness(async ({ asUrl, spotifyManifest }) => { @@ -319,9 +409,22 @@ test("security: device-code exposure on _ref read surfaces", async (t) => { throw new Error("unreachable: assert.ok would have thrown"); } - const approveResp = await fetch(`${asUrl}/consent/approve`, { + const reviewResp = await fetch(`${asUrl}/consent/review`, { body: JSON.stringify({ approval_id: consentEntry.approval_id, subject_id: "owner_local" }), - headers: { "Content-Type": "application/json" }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + const reviewText = await reviewResp.text(); + assert.equal(reviewResp.status, 200, reviewText); + const review = JSON.parse(reviewText) as { approval_review_revision: string; request_uri: string }; + assert.equal(review.request_uri, consentPar.request_uri); + + const approveResp = await fetch(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); assert.equal(approveResp.status, 200); diff --git a/reference-implementation/test/semantic-backfill-coordination.test.ts b/reference-implementation/test/semantic-backfill-coordination.test.ts index f6e81f80c..c7a54eec6 100644 --- a/reference-implementation/test/semantic-backfill-coordination.test.ts +++ b/reference-implementation/test/semantic-backfill-coordination.test.ts @@ -64,6 +64,7 @@ const baseManifest = { capabilities: { human_interaction: [] }, connector_id: "semantic-fence", display_name: "Semantic fence test", + manifest_uri: "https://sources.example/semantic-fence", protocol_version: "0.1.0", streams: [ { @@ -75,6 +76,8 @@ const baseManifest = { required: ["id", "subject"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, { name: "later", @@ -85,16 +88,21 @@ const baseManifest = { required: ["id", "subject"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], + version: "1.0.0", }; const semanticManifest = { connector_id: "semantic-fence", + protocol_version: "0.1.0", streams: [ { name: "first", query: { search: { semantic_fields: ["subject"] } } }, { name: "later", query: { search: { semantic_fields: ["subject"] } } }, ], + version: "1.0.0", }; test("semantic backfill holds one instance fence through later-stream meta completion while another instance proceeds", async () => { diff --git a/reference-implementation/test/semantic-retrieval.test.ts b/reference-implementation/test/semantic-retrieval.test.ts index 696364168..a034d0318 100644 --- a/reference-implementation/test/semantic-retrieval.test.ts +++ b/reference-implementation/test/semantic-retrieval.test.ts @@ -49,6 +49,8 @@ import { fileURLToPath } from "node:url"; import { canonicalConnectorKeyFromManifest } from "../server/connector-key.ts"; import { getDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; +import { isPostgresStorageBackend, postgresQuery } from "../server/postgres-storage.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; import { buildPostgresSemanticPlanRequests, DEFAULT_SEMANTIC_EMBEDDING_INPUT_MAX_CHARS, @@ -73,6 +75,9 @@ const UNKNOWN_PROFILE_ERROR = /PDPP_EMBEDDING_PROFILE_ID must be one of:/; const LOCAL_SUPERVISOR_CONTRACT_ERROR = /production local semantic execution requires PDPP_LOCAL_TRANSFORMER_SUPERVISOR_RESTART_CONTRACT=1/; const compareStrings = (a: unknown, b: unknown) => String(a).localeCompare(String(b)); +const SEMANTIC_A_SOURCE_ID = "https://registry.pdpp.dev/connectors/semantic-a"; +const SEMANTIC_B_SOURCE_ID = "https://registry.pdpp.dev/connectors/semantic-b"; +const SEMANTIC_A_TEST_INSTANCE_ID = "cin_semantic_a_test"; interface TestHttpServer { close: (callback: () => void) => void; @@ -143,6 +148,14 @@ const MANIFEST_A = { connector_id: "semantic-a", display_name: "Semantic A", protocol_version: "0.1.0", + source_declaration: { + declaration_version: "semantic-a-test-declaration-v1", + display: { name: "Semantic A" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation" }, + source: { id: SEMANTIC_A_SOURCE_ID, kind: "connector" as const }, + streams: [] as unknown[], + }, streams: [ { consent_time_field: "source_created_at", @@ -190,6 +203,7 @@ const MANIFEST_A = { properties: { body: { type: "string" }, id: { type: "string" }, + marker: { type: "string" }, post_title: { type: "string" }, source_created_at: { format: "date-time", type: "string" }, }, @@ -220,12 +234,21 @@ const MANIFEST_A = { ], version: "1.0.0", }; +MANIFEST_A.source_declaration.streams = MANIFEST_A.streams; const MANIFEST_B = { capabilities: { human_interaction: ["credentials"] }, connector_id: "semantic-b", display_name: "Semantic B", protocol_version: "0.1.0", + source_declaration: { + declaration_version: "semantic-b-test-declaration-v1", + display: { name: "Semantic B" }, + protocol_version: "0.1.0", + publisher: { id: "https://pdpp.dev/reference-implementation" }, + source: { id: SEMANTIC_B_SOURCE_ID, kind: "connector" as const }, + streams: [] as unknown[], + }, streams: [ { consent_time_field: "source_created_at", @@ -255,6 +278,7 @@ const MANIFEST_B = { ], version: "1.0.0", }; +MANIFEST_B.source_declaration.streams = MANIFEST_B.streams; async function issueOwnerToken(asUrl: string, subjectId = "owner_local"): Promise<string> { const clientId = "cli_longview"; @@ -287,10 +311,20 @@ interface ClientGrantParams { connector_id: string; purpose_code: string; purpose_description: string; - streams: Array<{ name: string; fields: string[] }>; + streams: Array<{ name: string; fields: string[]; instance_ids?: string[] }>; subject_id?: string; } +function sourceForConnector(connectorId: string): { id: string; kind: "connector" } { + if (connectorId === MANIFEST_A.connector_id) { + return MANIFEST_A.source_declaration.source; + } + if (connectorId === MANIFEST_B.connector_id) { + return MANIFEST_B.source_declaration.source; + } + throw new Error(`No test SourceDeclaration for connector ${connectorId}`); +} + async function approveClientGrant(asUrl: string, params: ClientGrantParams): Promise<Record<string, unknown>> { const { body: initiateBody } = await fetchJson(`${asUrl}/oauth/par`, { body: JSON.stringify({ @@ -299,7 +333,7 @@ async function approveClientGrant(asUrl: string, params: ClientGrantParams): Pro access_mode: params.access_mode, purpose_code: params.purpose_code, purpose_description: params.purpose_description, - source: { id: params.connector_id, kind: "connector" }, + source: sourceForConnector(params.connector_id), streams: params.streams, type: "https://pdpp.dev/data-access", }, @@ -310,14 +344,92 @@ async function approveClientGrant(asUrl: string, params: ClientGrantParams): Pro method: "POST", }); const initiate = asRecord(initiateBody); + const subjectId = params.subject_id || "owner_local"; + const reviewResponse = await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: subjectId }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(reviewResponse.status, 200, JSON.stringify(reviewResponse.body)); + const review = asRecord(reviewResponse.body); + assert.equal(typeof review.approval_review_revision, "string", "consent review returns a revision"); const { body: approved } = await fetchJson(`${asUrl}/consent/approve`, { - body: JSON.stringify({ request_uri: initiate.request_uri, subject_id: params.subject_id || "owner_local" }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: initiate.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", }); return asRecord(approved); } +async function materializeSemanticConnection(connectorId: string, connectorInstanceId: string): Promise<void> { + const now = "2026-01-01T00:00:00.000Z"; + await createRequestConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId, + createdAt: now, + displayName: connectorId, + ownerSubjectId: "owner_local", + sourceBinding: { kind: "test_account", label: connectorInstanceId }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, + }); +} + +async function listSemanticVectorRows(connectorId: string, recordKeys: readonly string[]) { + if (isPostgresStorageBackend()) { + const result = await postgresQuery( + ` + SELECT connector_instance_id, connector_id, scope_key, record_key + FROM semantic_search_blob + WHERE connector_id = $1 AND record_key = ANY($2::text[]) + ORDER BY connector_instance_id + `, + [connectorId, recordKeys] + ); + return result.rows; + } + + const db = getDb(); + const vectorTable = db.vectorIndexKind === "sqlite-vec" ? "semantic_search_rowid" : "semantic_search_blob"; + return db + .prepare(` + SELECT connector_instance_id, connector_id, scope_key, record_key + FROM ${vectorTable} + WHERE connector_id = ? AND record_key IN (?, ?) + ORDER BY connector_instance_id + `) + .all(connectorId, ...recordKeys); +} + +async function listSemanticMetaRows(connectorId: string) { + if (isPostgresStorageBackend()) { + const result = await postgresQuery( + ` + SELECT connector_instance_id + FROM semantic_search_meta + WHERE connector_id = $1 AND stream = 'posts' + ORDER BY connector_instance_id + `, + [connectorId] + ); + return result.rows; + } + + return getDb() + .prepare(` + SELECT connector_instance_id + FROM semantic_search_meta + WHERE connector_id = ? AND stream = 'posts' + ORDER BY connector_instance_id + `) + .all(connectorId); +} + interface SemanticRecordInput { emitted_at?: string; id: string; @@ -707,15 +819,23 @@ test("filtered semantic search rejects invalid filters and still-forbidden param await withHarness({}, async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl); const connectorA = MANIFEST_A.connector_id; - await ingest(rsUrl, ownerToken, connectorA, "posts", [ - { - id: "p1", - score: 5, - selftext: "secret body", - source_created_at: "2026-04-10T00:00:00Z", - title: "semantic filtered alpha", - }, - ]); + await materializeSemanticConnection(connectorA, SEMANTIC_A_TEST_INSTANCE_ID); + await ingest( + rsUrl, + ownerToken, + connectorA, + "posts", + [ + { + id: "p1", + score: 5, + selftext: "secret body", + source_created_at: "2026-04-10T00:00:00Z", + title: "semantic filtered alpha", + }, + ], + SEMANTIC_A_TEST_INSTANCE_ID + ); const rejectedOwnerQueries = [ "q=semantic&filter[source_created_at][gte]=2026-04-01T00:00:00Z", @@ -740,14 +860,16 @@ test("filtered semantic search rejects invalid filters and still-forbidden param connector_id: connectorA, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "semantic filtered retrieval test", - streams: [{ fields: ["id", "title", "source_created_at"], name: "posts" }], + streams: [ + { fields: ["id", "title", "source_created_at"], instance_ids: [SEMANTIC_A_TEST_INSTANCE_ID], name: "posts" }, + ], }); const unauthorized = await fetchJson( `${rsUrl}/v1/search/semantic?q=semantic&streams=posts&filter[selftext]=secret`, { headers: { Authorization: `Bearer ${String(approved.token)}` } } ); - assert.equal(unauthorized.status, 403); - assert.equal(errorCode(unauthorized.body), "field_not_granted"); + assert.equal(unauthorized.status, 400); + assert.equal(errorCode(unauthorized.body), "invalid_request"); }); }); @@ -789,16 +911,22 @@ test("client-token streams[] not in grant returns grant_stream_not_allowed", asy await withHarness({}, async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl); const connectorA = MANIFEST_A.connector_id; - await ingest(rsUrl, ownerToken, connectorA, "posts", [ - { id: "p1", selftext: "", source_created_at: "2026-04-01T00:00:00Z", title: "overdraft" }, - ]); + await materializeSemanticConnection(connectorA, SEMANTIC_A_TEST_INSTANCE_ID); + await ingest( + rsUrl, + ownerToken, + connectorA, + "posts", + [{ id: "p1", selftext: "", source_created_at: "2026-04-01T00:00:00Z", title: "overdraft" }], + SEMANTIC_A_TEST_INSTANCE_ID + ); const approved = await approveClientGrant(asUrl, { access_mode: "continuous", client_id: "longview", connector_id: connectorA, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "semantic test", - streams: [{ fields: ["id", "title"], name: "posts" }], // posts only + streams: [{ fields: ["id", "title"], instance_ids: [SEMANTIC_A_TEST_INSTANCE_ID], name: "posts" }], // posts only }); const { status, body } = await fetchJson(`${rsUrl}/v1/search/semantic?q=overdraft&streams=comments`, { headers: { Authorization: `Bearer ${String(approved.token)}` }, @@ -834,14 +962,22 @@ test("client grant authorizing only one of two declared semantic_fields restrict await withHarness({}, async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl); const connectorA = MANIFEST_A.connector_id; - await ingest(rsUrl, ownerToken, connectorA, "posts", [ - { - id: "p1", - selftext: "unauthorized field content", - source_created_at: "2026-04-01T00:00:00Z", - title: "overdraft story", - }, - ]); + await materializeSemanticConnection(connectorA, SEMANTIC_A_TEST_INSTANCE_ID); + await ingest( + rsUrl, + ownerToken, + connectorA, + "posts", + [ + { + id: "p1", + selftext: "unauthorized field content", + source_created_at: "2026-04-01T00:00:00Z", + title: "overdraft story", + }, + ], + SEMANTIC_A_TEST_INSTANCE_ID + ); // Grant only `title` — selftext is NOT in the client's projection. const approved = await approveClientGrant(asUrl, { access_mode: "continuous", @@ -849,7 +985,7 @@ test("client grant authorizing only one of two declared semantic_fields restrict connector_id: connectorA, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "semantic test subset", - streams: [{ fields: ["id", "title"], name: "posts" }], + streams: [{ fields: ["id", "title"], instance_ids: [SEMANTIC_A_TEST_INSTANCE_ID], name: "posts" }], }); const { status, body } = await fetchJson(`${rsUrl}/v1/search/semantic?q=${encodeURIComponent("overdraft story")}`, { headers: { Authorization: `Bearer ${String(approved.token)}` }, @@ -876,25 +1012,44 @@ test("client grant authorizing only one of two declared semantic_fields restrict }); }); -// ─── 14.14 — zero intersection stream contributes zero hits ───────────────── +// 14.14 - retained instance authority with current full-stream fields -test("stream declared in semantic_fields but with empty grant∩declared intersection contributes zero hits", async () => { +test("client grant without a retained field subset searches declared semantic_fields", async () => { await withHarness({}, async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl); const connectorA = MANIFEST_A.connector_id; - // Comments declares semantic_fields: ['body']. Grant authorizes only `id` - // — no overlap with declared semantic_fields. Should contribute zero hits - // AND return no per-stream error. - await ingest(rsUrl, ownerToken, connectorA, "comments", [ - { body: "something about overdrafts", id: "c1", source_created_at: "2026-04-01T00:00:00Z" }, - ]); + // Comments declares semantic_fields: ['body']. This approval path retains + // instance authority but no field subset, so semantic search follows the + // current full-stream grant behavior. + await materializeSemanticConnection(connectorA, SEMANTIC_A_TEST_INSTANCE_ID); + await ingest( + rsUrl, + ownerToken, + connectorA, + "comments", + [ + { + body: "something about overdrafts", + id: "c1", + marker: "not semantic", + source_created_at: "2026-04-01T00:00:00Z", + }, + ], + SEMANTIC_A_TEST_INSTANCE_ID + ); const approved = await approveClientGrant(asUrl, { access_mode: "continuous", client_id: "longview", connector_id: connectorA, purpose_code: "https://pdpp.dev/purpose/analytics", purpose_description: "zero-intersection test", - streams: [{ fields: ["id"], name: "comments" }], // id not in declared semantic_fields + streams: [ + { + fields: ["marker"], + instance_ids: [SEMANTIC_A_TEST_INSTANCE_ID], + name: "comments", + }, + ], }); const { status, body } = await fetchJson(`${rsUrl}/v1/search/semantic?q=overdrafts&streams=comments`, { headers: { Authorization: `Bearer ${String(approved.token)}` }, @@ -902,7 +1057,9 @@ test("stream declared in semantic_fields but with empty grant∩declared interse assert.equal(status, 200); const bodyRecord = asRecord(body); assert.equal(bodyRecord.object, "list"); - assert.deepEqual(bodyRecord.data, []); + const hits = asArray(bodyRecord.data); + assert.equal(hits.length, 1); + assert.equal(asRecord(hits[0]).record_key, "c1"); }); }); @@ -1458,55 +1615,45 @@ test("semantic upsert with an empty field deletes only that record, not the whol }); }); -test("semantic index metadata isolates instances and client search fans in across active bindings", async () => { +test("semantic index metadata isolates instances and client search fans in across granted active bindings", async () => { await withHarness({}, async ({ asUrl, rsUrl }) => { const ownerToken = await issueOwnerToken(asUrl); - const db = getDb(); - const now = new Date().toISOString(); - const insertInstance = db.prepare(` - INSERT INTO connector_instances( - connector_instance_id, owner_subject_id, connector_id, display_name, - status, source_kind, source_binding_key, source_binding_json, created_at, updated_at - ) VALUES(?, 'owner_local', ?, ?, 'active', 'account', ?, '{}', ?, ?) - `); - insertInstance.run("cin_semantic_work", MANIFEST_A.connector_id, "Semantic A work", "work", now, now); - insertInstance.run("cin_semantic_personal", MANIFEST_A.connector_id, "Semantic A personal", "personal", now, now); + await materializeSemanticConnection(MANIFEST_A.connector_id, "cin_semantic_work"); + await materializeSemanticConnection(MANIFEST_A.connector_id, "cin_semantic_personal"); const baseRecord = { - id: "shared-record-key", score: 1, selftext: "", source_created_at: "2026-04-01T00:00:00Z", subreddit: "pdpp", title: "semantic connector instance collision sentinel", }; - await ingest(rsUrl, ownerToken, MANIFEST_A.connector_id, "posts", [baseRecord], "cin_semantic_work"); - await ingest(rsUrl, ownerToken, MANIFEST_A.connector_id, "posts", [baseRecord], "cin_semantic_personal"); + await ingest( + rsUrl, + ownerToken, + MANIFEST_A.connector_id, + "posts", + [{ ...baseRecord, id: "work-record-key" }], + "cin_semantic_work" + ); + await ingest( + rsUrl, + ownerToken, + MANIFEST_A.connector_id, + "posts", + [{ ...baseRecord, id: "personal-record-key" }], + "cin_semantic_personal" + ); - const vectorTable = db.vectorIndexKind === "sqlite-vec" ? "semantic_search_rowid" : "semantic_search_blob"; - const indexed = db - .prepare(` - SELECT connector_instance_id, connector_id, scope_key, record_key - FROM ${vectorTable} - WHERE connector_id = ? AND record_key = ? - ORDER BY connector_instance_id - `) - .all(MANIFEST_A.connector_id, "shared-record-key"); + const indexed = await listSemanticVectorRows(MANIFEST_A.connector_id, ["personal-record-key", "work-record-key"]); assert.deepEqual( indexed.map((row: Record<string, unknown>) => row.connector_instance_id), ["cin_semantic_personal", "cin_semantic_work"], "semantic vector identity includes connector_instance_id" ); - const metaRows = db - .prepare(` - SELECT connector_instance_id - FROM semantic_search_meta - WHERE connector_id = ? AND stream = 'posts' - ORDER BY connector_instance_id - `) - .all(MANIFEST_A.connector_id); + const metaRows = await listSemanticMetaRows(MANIFEST_A.connector_id); assert.deepEqual( metaRows.map((row: Record<string, unknown>) => row.connector_instance_id), ["cin_semantic_personal", "cin_semantic_work"], @@ -1517,28 +1664,29 @@ test("semantic index metadata isolates instances and client search fans in acros access_mode: "continuous", client_id: "longview", connector_id: MANIFEST_A.connector_id, - purpose_code: "https://pdpp.dev/purpose/analytics", - purpose_description: "semantic instance isolation test", - streams: [{ fields: ["id", "title", "source_created_at"], name: "posts" }], + purpose_code: "https://pdpp.org/purpose/analytics", + purpose_description: "semantic instance fan-in test", + streams: [ + { + fields: ["id", "title", "source_created_at"], + instance_ids: ["cin_semantic_personal", "cin_semantic_work"], + name: "posts", + }, + ], }); + assert.ok(approved.token, `expected issued grant token, got ${JSON.stringify(approved)}`); const { status, body } = await fetchJson( - `${rsUrl}/v1/search/semantic?q=${encodeURIComponent(baseRecord.title)}&streams[]=posts`, + `${rsUrl}/v1/search/semantic?q=${encodeURIComponent(baseRecord.title)}&streams=posts`, { headers: { Authorization: `Bearer ${String(approved.token)}` } } ); - // With cross-binding search fan-in, two active connections under one - // connector are no longer an error — the runtime returns the union and - // each hit carries its `connection_id`. (Pre-fan-in this surfaced the - // scheduler's `ambiguous_connector_instance` error to the read path, - // which has been replaced by the typed `connection_not_found` / - // `ambiguous_connection` read-path errors covered by - // `storage-fan-in-read-contract.test.js` and the route-level - // `blob-fan-in-ambiguity.test.js`.) + assert.equal(status, 200); const hits = asArray(asRecord(body).data); - // Both bindings should contribute a hit for the shared record_key. - assert.equal(hits.length, 2, "fan-in returns one hit per binding"); + assert.equal(hits.length, 2, "fan-in returns one hit per granted active binding"); const cids = hits.map((h) => asRecord(h).connection_id).sort(compareStrings); assert.deepEqual(cids, ["cin_semantic_personal", "cin_semantic_work"]); + const keys = hits.map((h) => asRecord(h).record_key).sort(compareStrings); + assert.deepEqual(keys, ["personal-record-key", "work-record-key"]); }); }); diff --git a/reference-implementation/test/source-declaration-boundary.test.ts b/reference-implementation/test/source-declaration-boundary.test.ts new file mode 100644 index 000000000..d1baef4fd --- /dev/null +++ b/reference-implementation/test/source-declaration-boundary.test.ts @@ -0,0 +1,330 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import type { SourceDeclaration } from "@pdpp/reference-contract/public/source"; +import { requireSourceDeclaration, snapshotSourceDeclaration } from "../server/source-declaration.ts"; +import { + COLLECTION_PROFILE_URI, + snapshotContentAddressedSourceDeclarationFromLegacyConnectorManifest, + snapshotSourceDeclarationFromLegacyConnectorManifest, + sourceDeclarationFromLegacyConnectorManifest, +} from "../server/source-declaration-legacy-collection.ts"; + +const INVALID_DECLARATION_REGEX = /Invalid SourceDeclaration/; +const INVALID_EMBEDDED_SCHEMA_REGEX = /Invalid SourceDeclaration stream schema/; +const NONLOCAL_SCHEMA_REFERENCE_REGEX = /must be a local fragment reference/; +const INVALID_PUBLISHER_REGEX = /publisherId must be an absolute URI/; +const INVALID_SOURCE_REGEX = /sourceId must be an absolute URI/; +const MALFORMED_STREAM_REGEX = /manifest\.streams\[1\] must be an object/; +const IMPORT_REGEX = /from\s+["']([^"']+)["']/g; +const NO_CORE_COLLECTION_REGEX = /collection|legacy|connector-manifest-validation/i; +const RUNTIME_IMPORT_REGEX = /import\s+(?!type\s)[\s\S]*?from\s+["']([^"']+)["']/g; +const SOURCE_CONTRACT_PATH_REGEX = /\/src\/public\/source\.ts$/; +const PROJECTED_DECLARATION_VERSION_REGEX = /^reference\.legacy-connector-projection\.v1:sha256:[0-9a-f]{64}$/; + +const query = { + aggregations: { + count: true as const, + count_distinct: ["id"], + group_by: ["id"], + group_by_time: ["updated_at"], + max: ["updated_at"], + min: ["updated_at"], + sum: ["score"], + }, + expand: [{ default_limit: 1, max_limit: 2, name: "children" }], + range_filters: { updated_at: ["gte" as const, "lt" as const] }, + search: { lexical_fields: ["id"], semantic_fields: ["id"] }, +}; + +const stream = { + consent_time_field: "updated_at", + cursor_field: "updated_at", + description: "Items", + display: { detail: "Item records", label: "Items" }, + incremental: true, + name: "items", + primary_key: ["id"], + query, + relationships: [{ cardinality: "has_many" as const, foreign_key: "parent_id", name: "children", stream: "items" }], + schema: { + properties: { + id: { type: "string" }, + parent_id: { type: "string" }, + score: { type: "number" }, + updated_at: { format: "date-time", type: "string" }, + }, + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state" as const, + views: [{ fields: ["id", "updated_at"], id: "summary", label: "Summary" }], +}; + +const legacy = { + capabilities: { human_interaction: ["credentials"] }, + connector_id: "https://implementations.example/connectors/items", + connector_key: "items-local-key", + display_name: "Items", + profiles: [{ id: "summary", label: "Summary", streams: [{ name: "items", view: "summary" }] }], + protocol_version: "0.1.0", + runtime_requirements: { bindings: { network: { required: true } } }, + streams: [stream], + version: "7.2.0", +}; + +const attribution = { + declarationVersion: "accepted-declaration-v1", + publisherId: "https://local.example/publishers/accepted-connectors", + sourceId: "https://sources.example/items", +}; + +function coreDeclaration(kind: "connector" | "provider_native"): SourceDeclaration { + const { incremental: _incremental, ...commonStream } = stream; + return { + declaration_version: "native-v1", + display: { name: "Items" }, + protocol_version: "0.1.0", + publisher: { id: "https://local.example/publishers/accepted-sources" }, + source: { id: "https://sources.example/items", kind }, + streams: [commonStream], + } as SourceDeclaration; +} + +test("Core-only connector and provider-native declarations use identical validation", () => { + const connector = requireSourceDeclaration(coreDeclaration("connector")); + const native = requireSourceDeclaration(coreDeclaration("provider_native")); + assert.deepEqual(connector.streams, native.streams); + assert.equal(connector.extensions, undefined); + assert.equal(native.extensions, undefined); + + for (const kind of ["connector", "provider_native"] as const) { + assert.throws( + () => requireSourceDeclaration({ ...coreDeclaration(kind), source: { id: "items-local", kind } }), + INVALID_DECLARATION_REGEX + ); + } +}); + +test("embedded stream schemas are valid 2020-12 schemas with local references", () => { + const malformed = coreDeclaration("connector"); + const [malformedStream] = malformed.streams; + assert.ok(malformedStream); + malformed.streams[0] = { + ...malformedStream, + schema: { properties: { id: { type: "string" } }, type: "bananas" }, + }; + assert.throws(() => requireSourceDeclaration(malformed), INVALID_EMBEDDED_SCHEMA_REGEX); + + const remoteReference = coreDeclaration("provider_native"); + const [remoteReferenceStream] = remoteReference.streams; + assert.ok(remoteReferenceStream); + remoteReference.streams[0] = { + ...remoteReferenceStream, + schema: { + properties: { id: { $ref: "https://schemas.example/identity.json" } }, + type: "object", + }, + }; + assert.throws(() => requireSourceDeclaration(remoteReference), NONLOCAL_SCHEMA_REFERENCE_REGEX); + + const localReference = coreDeclaration("connector"); + const [localReferenceStream] = localReference.streams; + assert.ok(localReferenceStream); + localReference.streams[0] = { + ...localReferenceStream, + schema: { + $defs: { identifier: { type: "string" } }, + ...localReferenceStream.schema, + properties: { + ...(localReferenceStream.schema.properties as Record<string, unknown>), + local_id: { $ref: "#/$defs/identifier" }, + }, + type: "object", + }, + }; + assert.doesNotThrow(() => requireSourceDeclaration(localReference)); +}); + +test("trusted native declaration stays in parity with duplicated serving metadata", () => { + const nativeManifest = JSON.parse( + readFileSync(fileURLToPath(new URL("../manifests/northstar-hr.json", import.meta.url)), "utf8") + ) as Record<string, unknown>; + const declaration = requireSourceDeclaration(nativeManifest.source_declaration); + assert.deepEqual(declaration.source, { + id: nativeManifest.provider_id, + kind: "provider_native", + }); + + const servingStreams = nativeManifest.streams as Record<string, unknown>[]; + assert.deepEqual( + declaration.streams.map((declarationStream) => declarationStream.name), + servingStreams.map((servingStream) => servingStream.name) + ); + for (const declarationStream of declaration.streams) { + const servingStream = servingStreams.find((candidate) => candidate.name === declarationStream.name); + assert.ok(servingStream, `serving metadata includes ${declarationStream.name}`); + assert.deepEqual( + declarationStream.schema, + servingStream.schema, + `${declarationStream.name} schema remains identical` + ); + const servingPrimaryKey = Array.isArray(servingStream.primary_key) + ? servingStream.primary_key + : [servingStream.primary_key]; + assert.deepEqual( + declarationStream.primary_key, + servingPrimaryKey, + `${declarationStream.name} primary key remains identical` + ); + } +}); + +test("legacy adapter separates public source identity and Collection execution metadata", () => { + const declaration = sourceDeclarationFromLegacyConnectorManifest(legacy, attribution); + assert.deepEqual(declaration.source, { id: attribution.sourceId, kind: "connector" }); + assert.equal(declaration.publisher.id, attribution.publisherId); + assert.equal(declaration.declaration_version, attribution.declarationVersion); + assert.notEqual(declaration.declaration_version, legacy.version); + assert.equal(JSON.stringify(declaration).includes("items-local-key"), false); + assert.deepEqual(declaration.streams[0]?.query, query); + assert.equal("incremental" in (declaration.streams[0] as object), false); + assert.deepEqual(declaration.extensions?.[COLLECTION_PROFILE_URI], { + capabilities: legacy.capabilities, + connector: { id: legacy.connector_id, version: legacy.version }, + runtime_requirements: legacy.runtime_requirements, + streams: [{ incremental: true, name: "items" }], + }); + assert.equal(JSON.stringify(declaration).includes("connection_id"), false); +}); + +test("legacy projection revision identifies the exact normalized declaration content", () => { + const projectionAttribution = { + connectorImplementationId: legacy.connector_id, + publisherId: attribution.publisherId, + sourceId: attribution.sourceId, + }; + const original = snapshotContentAddressedSourceDeclarationFromLegacyConnectorManifest( + structuredClone(legacy), + projectionAttribution + ); + const reordered = Object.fromEntries(Object.entries(structuredClone(legacy)).reverse()); + const sameContent = snapshotContentAddressedSourceDeclarationFromLegacyConnectorManifest( + reordered, + projectionAttribution + ); + assert.equal(sameContent.declaration_version, original.declaration_version); + assert.match(original.declaration_version, PROJECTED_DECLARATION_VERSION_REGEX); + + const connectorRelease = structuredClone(legacy); + connectorRelease.version = "7.2.1"; + const changedConnectorRelease = snapshotContentAddressedSourceDeclarationFromLegacyConnectorManifest( + connectorRelease, + projectionAttribution + ); + assert.notEqual(changedConnectorRelease.declaration_version, original.declaration_version); + + const changedSelection = structuredClone(legacy); + const [changedStream] = changedSelection.streams; + assert.ok(changedStream); + changedStream.selection.resources = false; + const changedAuthorizationContent = snapshotContentAddressedSourceDeclarationFromLegacyConnectorManifest( + changedSelection, + projectionAttribution + ); + assert.notEqual(changedAuthorizationContent.declaration_version, original.declaration_version); +}); + +test("legacy adapter represents zero runtime binding requirements explicitly", () => { + const { runtime_requirements: _runtimeRequirements, ...legacyWithoutRequirements } = legacy; + const declaration = sourceDeclarationFromLegacyConnectorManifest(legacyWithoutRequirements, attribution); + assert.deepEqual(declaration.extensions?.[COLLECTION_PROFILE_URI], { + capabilities: legacy.capabilities, + connector: { id: legacy.connector_id, version: legacy.version }, + runtime_requirements: { bindings: {} }, + streams: [{ incremental: true, name: "items" }], + }); +}); + +test("legacy adapter requires accepted attribution and never invents provider authority", () => { + assert.throws( + () => sourceDeclarationFromLegacyConnectorManifest(legacy, { ...attribution, publisherId: "local" }), + INVALID_PUBLISHER_REGEX + ); + assert.throws( + () => sourceDeclarationFromLegacyConnectorManifest(legacy, { ...attribution, sourceId: "items-local" }), + INVALID_SOURCE_REGEX + ); +}); + +test("common snapshot returns a detached immutable value for either source kind", () => { + for (const kind of ["connector", "provider_native"] as const) { + const input = coreDeclaration(kind); + const snapshot = snapshotSourceDeclaration(input); + input.display.name = "Changed input"; + assert.equal(snapshot.display.name, "Items"); + assert.equal(Object.isFrozen(snapshot), true); + assert.equal(Object.isFrozen(snapshot.streams[0]), true); + } + + const legacyInput = structuredClone(legacy); + const snapshot = snapshotSourceDeclarationFromLegacyConnectorManifest(legacyInput, attribution); + legacyInput.display_name = "Changed legacy runtime manifest"; + assert.equal(snapshot.display.name, "Items"); + assert.equal(Object.isFrozen(snapshot), true); + assert.equal(Object.isFrozen(snapshot.streams[0]), true); +}); + +test("legacy adapter normalizes only the exact append semantics alias and rejects malformed streams", () => { + const appendLegacy = { ...legacy, streams: [{ ...stream, semantics: "append" }] }; + const declaration = sourceDeclarationFromLegacyConnectorManifest(appendLegacy, attribution); + assert.equal(declaration.streams[0]?.semantics, "append_only"); + + assert.throws( + () => + sourceDeclarationFromLegacyConnectorManifest( + { ...legacy, streams: [legacy.streams[0], "not-a-stream"] }, + attribution + ), + MALFORMED_STREAM_REGEX + ); +}); + +test("legacy corpus projects into the neutral SourceDeclaration contract", () => { + const manifestDirectory = fileURLToPath(new URL("../../packages/polyfill-connectors/manifests/", import.meta.url)); + const rejected = new Map<string, string>(); + for (const filename of readdirSync(manifestDirectory) + .filter((name) => name.endsWith(".json")) + .sort()) { + const manifest = JSON.parse(readFileSync(`${manifestDirectory}/${filename}`, "utf8")) as Record<string, unknown>; + try { + sourceDeclarationFromLegacyConnectorManifest(manifest, { + connectorImplementationId: + typeof manifest.manifest_uri === "string" ? manifest.manifest_uri : String(manifest.connector_id), + declarationVersion: `legacy-projection:${String(manifest.version)}`, + publisherId: "https://local.example/publishers/accepted-connectors", + sourceId: `https://sources.example/${filename.slice(0, -5)}`, + }); + } catch (error) { + rejected.set(filename, error instanceof Error ? error.message : String(error)); + } + } + assert.deepEqual([...rejected.entries()], []); +}); + +test("Core-only dependency oracle resolves a standalone source contract module", () => { + const moduleText = readFileSync(new URL("../server/source-declaration.ts", import.meta.url), "utf8"); + const imports = [...moduleText.matchAll(IMPORT_REGEX)].map((match) => match[1]); + assert.deepEqual(imports, ["node:module", "@pdpp/reference-contract/public/source"]); + assert.doesNotMatch(moduleText, NO_CORE_COLLECTION_REGEX); + + const contractUrl = import.meta.resolve("@pdpp/reference-contract/public/source"); + assert.match(contractUrl, SOURCE_CONTRACT_PATH_REGEX); + const contractText = readFileSync(fileURLToPath(contractUrl), "utf8"); + const runtimeImports = [...contractText.matchAll(RUNTIME_IMPORT_REGEX)].map((match) => match[1]); + assert.deepEqual(runtimeImports, []); +}); diff --git a/reference-implementation/test/source-declaration-snapshot-barrier.test.ts b/reference-implementation/test/source-declaration-snapshot-barrier.test.ts new file mode 100644 index 000000000..4481c5d13 --- /dev/null +++ b/reference-implementation/test/source-declaration-snapshot-barrier.test.ts @@ -0,0 +1,910 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + approveGrant, + configureNativeManifest, + createHostedMcpGrantPackage, + getGrantPackageAccess, + getPendingConsent, + initiateGrant, + introspect, + issueToken, + parsePendingConsentRequestUri, + registerConnector, + requireResolvedPersistedGrantState, +} from "../server/auth.ts"; +import { getDb } from "../server/db.ts"; +import { + createSqliteConnectorInstanceStore, + makeDefaultAccountConnectorInstanceId, +} from "../server/stores/connector-instance-store.ts"; +import { createSqliteConsentDeviceAuthDriver } from "./helpers/sqlite-consent-device-auth-driver.ts"; + +interface PendingPayload { + source_declaration_snapshot: { + declaration: { + declaration_version: string; + extensions?: Record<string, { connector?: { id?: string; version?: string } }>; + publisher: { id: string }; + source: { id: string; kind: string }; + streams: Record<string, unknown>[]; + }; + accepted_revision_reference?: string; + declaration_version: string; + publisher_attribution?: { id: string; status: "unverified" }; + resource_authority?: { + authority_binding?: string; + status: "local_operator_provisioned" | "verified"; + }; + snapshot_version: string; + source: { id: string; kind: string }; + }; +} + +interface ResolvedStream { + fields: string[]; + instance_ids: string[]; + name: string; + resources?: string[]; + time_constraint?: { field: string; since?: string }; +} + +const MISSING_SNAPSHOT_RE = /declaration snapshot is missing/; +const INVALID_DECLARATION_RE = /Invalid SourceDeclaration/; +const DECLARATION_METADATA_MISMATCH_RE = /snapshot metadata does not match its bytes/; +const SNAPSHOT_SHAPE_RE = /snapshot shape is unsupported/; +const SNAPSHOT_DERIVATION_RE = /not derivable from the retained declaration/; +const INELIGIBLE_INSTANCE_RE = /does not exist|not found|not active|does not belong/; +const LEGACY_CONNECTION_ID_RE = /additional properties|Unsupported stream selection fields.*connection_id/; +const NO_ACTIVE_INSTANCE_RE = /exactly one eligible instance.*found 0/; +const MULTIPLE_ACTIVE_INSTANCES_RE = /exactly one eligible instance.*found 2/; +const MULTIPLE_LOCAL_BINDINGS_RE = /multiple local fulfillment bindings/; +const PURPOSE_CODE_RE = /purpose_code/; +const INVALID_TIME_RANGE_RE = /source\.selection\.invalid_time_range/; +const COMPOUND_RESOURCE_RE = /compound resource key has the wrong shape/; +const INVALID_NATIVE_INSTANCE_RE = /must equal its configured local instance/; +const GRANT_BINDING_RE = /Grant is malformed|grant/i; +const PROJECTED_DECLARATION_VERSION_RE = /^reference\.legacy-connector-projection\.v1:sha256:[0-9a-f]{64}$/; + +function loadNativeManifest(): Record<string, unknown> { + return JSON.parse(readFileSync(new URL("../manifests/northstar-hr.json", import.meta.url), "utf8")); +} + +async function seedActiveSpotifyInstance(connectorInstanceId: string, account: string): Promise<void> { + const now = new Date().toISOString(); + await createSqliteConnectorInstanceStore().upsert({ + connectorId: "spotify", + connectorInstanceId, + createdAt: now, + displayName: account, + ownerSubjectId: "owner_local", + sourceBinding: { account }, + sourceBindingKey: account, + sourceKind: "account", + status: "active", + updatedAt: now, + }); +} + +function customCoreSourceManifest(connectorKey: string, sourceId: string): Record<string, unknown> { + const streams = [ + { + name: "items", + primary_key: ["id"], + schema: { + properties: { id: { type: "string" }, label: { type: "string" } }, + required: ["id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + ]; + return { + capabilities: { human_interaction: [] }, + connector_id: connectorKey, + connector_key: connectorKey, + display_name: "Custom Core source", + manifest_uri: `https://implementations.example/connectors/${connectorKey}`, + protocol_version: "0.1.0", + source_declaration: { + declaration_version: `${connectorKey}-declaration-v1`, + display: { name: "Custom Core source" }, + protocol_version: "0.1.0", + publisher: { id: "https://publishers.example/source-tests" }, + source: { id: sourceId, kind: "connector" }, + streams, + }, + streams, + version: "1.0.0", + }; +} + +test("source declaration snapshot survives same-version replacement and deletion through issuance and evidence", async () => { + const driver = createSqliteConsentDeviceAuthDriver(); + await driver.setup(); + try { + await seedActiveSpotifyInstance("cin_spotify_primary", "primary@example.com"); + const started = await driver.startPendingConsent({ + streams: [ + { + fields: ["genres"], + name: "top_artists", + resources: ["artist-1"], + time_range: { since: "2026-01-01T00:00:00Z" }, + }, + ], + }); + const deviceCode = parsePendingConsentRequestUri(started.request_uri); + assert.ok(deviceCode); + + const pendingRow = getDb() + .prepare("SELECT params_json FROM pending_consents WHERE device_code = ?") + .get(deviceCode) as { params_json: string }; + const retained = JSON.parse(pendingRow.params_json) as PendingPayload; + const registeredManifestRow = getDb() + .prepare("SELECT manifest FROM connectors WHERE connector_id = ?") + .get("spotify") as { manifest: string }; + const registeredManifest = JSON.parse(registeredManifestRow.manifest) as Record<string, unknown>; + const projectedDeclarationVersion = retained.source_declaration_snapshot.declaration_version; + assert.match(projectedDeclarationVersion, PROJECTED_DECLARATION_VERSION_RE); + assert.equal(retained.source_declaration_snapshot.snapshot_version, "reference.source-declaration-snapshot.v1"); + assert.equal(retained.source_declaration_snapshot.declaration_version, projectedDeclarationVersion); + assert.deepEqual(retained.source_declaration_snapshot.source, { + id: driver.getRegisteredConnectorId(), + kind: "connector", + }); + assert.deepEqual(retained.source_declaration_snapshot.declaration.source, { + id: driver.getRegisteredConnectorId(), + kind: "connector", + }); + assert.deepEqual(retained.source_declaration_snapshot.declaration.publisher, { + id: "https://pdpp.dev/reference-implementation", + }); + assert.equal( + retained.source_declaration_snapshot.declaration.declaration_version, + retained.source_declaration_snapshot.declaration_version + ); + assert.equal("connector_id" in retained.source_declaration_snapshot.declaration, false); + assert.equal("version" in retained.source_declaration_snapshot.declaration, false); + + const collectionExtension = + retained.source_declaration_snapshot.declaration.extensions?.["https://pdpp.org/profile/collection"]; + assert.ok(collectionExtension); + assert.deepEqual(collectionExtension.connector, { + id: registeredManifest.manifest_uri, + version: registeredManifest.version, + }); + + const replacement = structuredClone(retained.source_declaration_snapshot.declaration) as Record<string, unknown>; + const replacementStreams = replacement.streams as Record<string, unknown>[]; + replacementStreams[0] = { + ...replacementStreams[0], + consent_time_field: "replacement_time", + schema: { + properties: { replacement_only: { type: "string" }, replacement_time: { type: "string" } }, + type: "object", + }, + }; + getDb() + .prepare("UPDATE connectors SET manifest = ? WHERE connector_id = ?") + .run(JSON.stringify(replacement), "spotify"); + + const displayedAfterReplacement = await getPendingConsent(deviceCode); + assert.ok(displayedAfterReplacement); + const displayedStreams = displayedAfterReplacement.resolvedStreams as ResolvedStream[]; + const [displayedStream] = displayedStreams; + assert.ok(displayedStream); + assert.ok(displayedStream.fields.includes("id")); + assert.ok(displayedStream.fields.includes("name")); + assert.ok(displayedStream.fields.includes("genres")); + assert.ok(!displayedStream.fields.includes("popularity")); + assert.ok(!displayedStream.fields.includes("replacement_only")); + assert.equal(displayedStream.time_constraint?.field, "source_updated_at"); + + // The production schema protects active connection rows with a connector + // FK. Disable it only for this mutation barrier so the test can model an + // independently lost declaration catalog without deleting eligibility. + getDb().pragma("foreign_keys = OFF"); + getDb().prepare("DELETE FROM connectors WHERE connector_id = ?").run("spotify"); + getDb().pragma("foreign_keys = ON"); + const displayedAfterDeletion = await getPendingConsent(deviceCode); + assert.deepEqual(displayedAfterDeletion?.resolvedStreams, displayedAfterReplacement.resolvedStreams); + + const reviewed = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + assert.ok(typeof reviewed?.reviewRevision === "string"); + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: reviewed?.reviewRevision, + }); + const issuedStreams = approved.grant.streams as unknown as ResolvedStream[]; + const [issuedStream] = issuedStreams; + assert.ok(issuedStream); + assert.ok(issuedStream.fields.includes("id")); + assert.ok(!issuedStream.fields.includes("replacement_only")); + assert.equal(issuedStream.instance_ids.length, 1); + assert.notEqual(issuedStream.instance_ids[0], "spotify"); + assert.deepEqual(issuedStream.resources, ["artist-1"]); + assert.deepEqual(issuedStream.time_constraint, { + field: "source_updated_at", + since: "2026-01-01T00:00:00Z", + }); + assert.deepEqual(approved.grant.source_declaration, { + version: projectedDeclarationVersion, + }); + assert.equal((approved.grant.source as { id?: string } | undefined)?.id, driver.getRegisteredConnectorId()); + assert.equal("manifest_version" in approved.grant, false); + assert.deepEqual(approved.grant.client, { client_id: driver.getRegisteredClientId() }); + assert.equal("connection_id" in issuedStream, false); + assert.equal("time_range" in issuedStream, false); + assert.equal("view" in issuedStream, false); + + const tokenState = await introspect(approved.token); + assert.equal(tokenState.active, true); + assert.ok(tokenState.grant); + assert.deepEqual(((tokenState.grant as { streams: ResolvedStream[] }).streams as ResolvedStream[])[0]?.fields, [ + "genres", + "id", + "name", + ]); + const persistedGrantJson = JSON.stringify(approved.grant); + const grantMutations = [ + { field: "grant_id", value: "grant_tampered" }, + { field: "subject", value: { id: "owner_tampered" } }, + { field: "client", value: { client_id: "client_tampered" } }, + { field: "access_mode", value: "single_use" }, + { field: "expires_at", value: "2099-01-01T00:00:00.000Z" }, + { field: "version", value: "0.0.9" }, + ]; + for (const mutation of grantMutations) { + const malformedGrant = structuredClone(approved.grant) as Record<string, unknown>; + malformedGrant[mutation.field] = mutation.value; + getDb() + .prepare("UPDATE grants SET grant_json = ? WHERE grant_id = ?") + .run(JSON.stringify(malformedGrant), approved.grant.grant_id); + // biome-ignore lint/performance/noAwaitInLoops: each mutation must be restored before the next persisted-row probe. + assert.equal((await introspect(approved.token)).active, false, `tampered ${mutation.field} must fail closed`); + } + getDb() + .prepare("UPDATE grants SET grant_json = ? WHERE grant_id = ?") + .run(persistedGrantJson, approved.grant.grant_id); + + getDb().prepare("UPDATE tokens SET client_id = ? WHERE token_id = ?").run("client_tampered", approved.token); + assert.equal( + (await introspect(approved.token)).active, + false, + "token/grant client binding mismatch must fail closed" + ); + getDb() + .prepare("UPDATE tokens SET client_id = ? WHERE token_id = ?") + .run(driver.getRegisteredClientId(), approved.token); + + const tokenCountBefore = ( + getDb().prepare("SELECT COUNT(*) AS count FROM tokens WHERE grant_id = ?").get(approved.grant.grant_id) as { + count: number; + } + ).count; + await assert.rejects( + () => + issueToken( + approved.grant.grant_id as string, + "owner_local", + "client_tampered", + approved.grant.expires_at as string | null + ), + GRANT_BINDING_RE + ); + const tokenCountAfter = ( + getDb().prepare("SELECT COUNT(*) AS count FROM tokens WHERE grant_id = ?").get(approved.grant.grant_id) as { + count: number; + } + ).count; + assert.equal(tokenCountAfter, tokenCountBefore, "binding mismatch must be rejected before token insertion"); + const persistedRow = getDb() + .prepare(`SELECT grant_id AS persisted_grant_id, + subject_id AS grant_subject_id, + client_id AS grant_client_id, + access_mode AS grant_access_mode, + expires_at AS grant_expires_at, + grant_json, + storage_binding_json + FROM grants + WHERE grant_id = ?`) + .get(approved.grant.grant_id) as { grant_json: string; storage_binding_json: string }; + const consumedState = await requireResolvedPersistedGrantState(persistedRow); + assert.deepEqual((consumedState.grant.streams as unknown as ResolvedStream[])[0]?.fields, ["genres", "id", "name"]); + + const evidenceRows = getDb() + .prepare( + "SELECT event_type, data_json FROM spine_events WHERE grant_id = ? AND event_type IN ('consent.approved', 'grant.issued') ORDER BY event_seq" + ) + .all(approved.grant.grant_id) as { data_json: string; event_type: string }[]; + assert.deepEqual( + evidenceRows.map((row) => row.event_type), + ["consent.approved", "grant.issued"] + ); + for (const row of evidenceRows) { + const data = JSON.parse(row.data_json) as { + resolved_streams: ResolvedStream[]; + source_declaration_snapshot: PendingPayload["source_declaration_snapshot"]; + }; + assert.equal(data.source_declaration_snapshot.snapshot_version, "reference.source-declaration-snapshot.v1"); + assert.deepEqual(data.source_declaration_snapshot.source, { + id: driver.getRegisteredConnectorId(), + kind: "connector", + }); + assert.deepEqual(data.source_declaration_snapshot.declaration, retained.source_declaration_snapshot.declaration); + assert.deepEqual(data.resolved_streams[0]?.time_constraint, { + field: "source_updated_at", + since: "2026-01-01T00:00:00Z", + }); + } + + const manifestWithPreset = structuredClone(registeredManifest); + manifestWithPreset.source_declaration = { + ...retained.source_declaration_snapshot.declaration, + selection_presets: [ + { id: "artists-basic", label: "Basic artists", streams: [{ name: "top_artists", view: "basic" }] }, + ], + }; + await registerConnector(manifestWithPreset); + const presetStarted = await initiateGrant({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + selection_preset: "artists-basic", + source: { id: driver.getRegisteredConnectorId(), kind: "connector" }, + type: "https://pdpp.dev/data-access", + }, + ], + client_id: driver.getRegisteredClientId(), + }); + const presetDeviceCode = parsePendingConsentRequestUri(presetStarted.request_uri); + assert.ok(presetDeviceCode); + const presetPending = await getPendingConsent(presetDeviceCode); + assert.ok(presetPending); + assert.deepEqual((presetPending.resolvedStreams as ResolvedStream[])[0]?.fields, ["id", "name", "genres"]); + const presetReview = await getPendingConsent(presetDeviceCode, { finalizeReview: true, subjectId: "owner_local" }); + assert.ok(typeof presetReview?.reviewRevision === "string"); + const presetApproved = await approveGrant(presetDeviceCode, "owner_local", { + approval_review_revision: presetReview?.reviewRevision, + }); + assert.equal(presetApproved.grant.selection_preset, "artists-basic"); + assert.deepEqual((presetApproved.grant.streams as unknown as ResolvedStream[])[0]?.fields, [ + "id", + "name", + "genres", + ]); + + const forgedInstanceStarted = await initiateGrant({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: driver.getRegisteredConnectorId(), kind: "connector" }, + streams: [{ instance_ids: ["forged-instance"], name: "top_artists" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: driver.getRegisteredClientId(), + }); + const forgedInstanceDeviceCode = parsePendingConsentRequestUri(forgedInstanceStarted.request_uri); + assert.ok(forgedInstanceDeviceCode); + await assert.rejects( + () => getPendingConsent(forgedInstanceDeviceCode, { subjectId: "owner_local" }), + INELIGIBLE_INSTANCE_RE + ); + await assert.rejects( + () => + initiateGrant({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source: { id: driver.getRegisteredConnectorId(), kind: "connector" }, + streams: [{ connection_id: "legacy-public-alias", name: "top_artists" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: driver.getRegisteredClientId(), + }), + LEGACY_CONNECTION_ID_RE + ); + + const missingSnapshotStarted = await driver.startPendingConsent(); + const missingSnapshotDeviceCode = parsePendingConsentRequestUri(missingSnapshotStarted.request_uri); + assert.ok(missingSnapshotDeviceCode); + const missingSnapshotRow = getDb() + .prepare("SELECT params_json FROM pending_consents WHERE device_code = ?") + .get(missingSnapshotDeviceCode) as { params_json: string }; + const withoutSnapshot = JSON.parse(missingSnapshotRow.params_json) as Record<string, unknown>; + Reflect.deleteProperty(withoutSnapshot, "source_declaration_snapshot"); + getDb() + .prepare("UPDATE pending_consents SET params_json = ? WHERE device_code = ?") + .run(JSON.stringify(withoutSnapshot), missingSnapshotDeviceCode); + await assert.rejects(() => getPendingConsent(missingSnapshotDeviceCode), MISSING_SNAPSHOT_RE); + + const invalidDeclarationStarted = await driver.startPendingConsent(); + const invalidDeclarationDeviceCode = parsePendingConsentRequestUri(invalidDeclarationStarted.request_uri); + assert.ok(invalidDeclarationDeviceCode); + const invalidDeclarationRow = getDb() + .prepare("SELECT params_json FROM pending_consents WHERE device_code = ?") + .get(invalidDeclarationDeviceCode) as { params_json: string }; + const invalidDeclarationRequest = JSON.parse(invalidDeclarationRow.params_json) as PendingPayload; + Reflect.deleteProperty(invalidDeclarationRequest.source_declaration_snapshot.declaration, "publisher"); + getDb() + .prepare("UPDATE pending_consents SET params_json = ? WHERE device_code = ?") + .run(JSON.stringify(invalidDeclarationRequest), invalidDeclarationDeviceCode); + await assert.rejects(() => getPendingConsent(invalidDeclarationDeviceCode), INVALID_DECLARATION_RE); + + const mismatchedVersionStarted = await driver.startPendingConsent(); + const mismatchedVersionDeviceCode = parsePendingConsentRequestUri(mismatchedVersionStarted.request_uri); + assert.ok(mismatchedVersionDeviceCode); + const mismatchedVersionRow = getDb() + .prepare("SELECT params_json FROM pending_consents WHERE device_code = ?") + .get(mismatchedVersionDeviceCode) as { params_json: string }; + const mismatchedVersionRequest = JSON.parse(mismatchedVersionRow.params_json) as PendingPayload; + mismatchedVersionRequest.source_declaration_snapshot.declaration_version = "tampered-declaration-revision"; + getDb() + .prepare("UPDATE pending_consents SET params_json = ? WHERE device_code = ?") + .run(JSON.stringify(mismatchedVersionRequest), mismatchedVersionDeviceCode); + await assert.rejects(() => getPendingConsent(mismatchedVersionDeviceCode), DECLARATION_METADATA_MISMATCH_RE); + + const unknownShapeStarted = await driver.startPendingConsent(); + const unknownShapeDeviceCode = parsePendingConsentRequestUri(unknownShapeStarted.request_uri); + assert.ok(unknownShapeDeviceCode); + const unknownShapeRow = getDb() + .prepare("SELECT params_json FROM pending_consents WHERE device_code = ?") + .get(unknownShapeDeviceCode) as { params_json: string }; + const unknownShapeRequest = JSON.parse(unknownShapeRow.params_json) as PendingPayload & Record<string, unknown>; + (unknownShapeRequest.source_declaration_snapshot as unknown as Record<string, unknown>).legacy_manifest = {}; + getDb() + .prepare("UPDATE pending_consents SET params_json = ? WHERE device_code = ?") + .run(JSON.stringify(unknownShapeRequest), unknownShapeDeviceCode); + await assert.rejects(() => getPendingConsent(unknownShapeDeviceCode), SNAPSHOT_SHAPE_RE); + + const underivedStarted = await driver.startPendingConsent(); + const underivedDeviceCode = parsePendingConsentRequestUri(underivedStarted.request_uri); + assert.ok(underivedDeviceCode); + const underivedRow = getDb() + .prepare("SELECT params_json FROM pending_consents WHERE device_code = ?") + .get(underivedDeviceCode) as { params_json: string }; + const underivedRequest = JSON.parse(underivedRow.params_json) as PendingPayload; + const retainedStreams = ( + underivedRequest.source_declaration_snapshot as unknown as { resolved_streams: ResolvedStream[] } + ).resolved_streams; + assert.ok(retainedStreams[0]); + retainedStreams[0].fields = ["id"]; + getDb() + .prepare("UPDATE pending_consents SET params_json = ? WHERE device_code = ?") + .run(JSON.stringify(underivedRequest), underivedDeviceCode); + await assert.rejects(() => getPendingConsent(underivedDeviceCode), SNAPSHOT_DERIVATION_RE); + } finally { + await driver.teardown(); + } +}); + +test("registered Core source IDs resolve to one exact local connector binding", async () => { + const driver = createSqliteConsentDeviceAuthDriver(); + await driver.setup(); + try { + const connectorKey = "custom_core_source_a"; + const connectorInstanceId = "cin_custom_core_source_a"; + const sourceId = "https://sources.example/custom-core-source"; + await registerConnector(customCoreSourceManifest(connectorKey, sourceId)); + const now = new Date().toISOString(); + await createSqliteConnectorInstanceStore().upsert({ + connectorId: connectorKey, + connectorInstanceId, + createdAt: now, + displayName: "Custom Core source account", + ownerSubjectId: "owner_local", + sourceBinding: { fixture: "custom-core-source" }, + sourceBindingKey: "custom-core-source", + sourceKind: "manual", + status: "active", + updatedAt: now, + }); + + const started = await initiateGrant({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/custom_source_test", + source: { id: sourceId, kind: "connector" }, + streams: [{ name: "items" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: driver.getRegisteredClientId(), + }); + const deviceCode = parsePendingConsentRequestUri(started.request_uri); + assert.ok(deviceCode); + const pendingRow = getDb() + .prepare("SELECT params_json FROM pending_consents WHERE device_code = ?") + .get(deviceCode) as { params_json: string }; + const pending = JSON.parse(pendingRow.params_json) as { + source_binding: { id: string; kind: string }; + storage_binding: { connector_id: string }; + }; + assert.deepEqual(pending.source_binding, { id: sourceId, kind: "connector" }); + assert.deepEqual(pending.storage_binding, { connector_id: connectorKey }); + + const reviewed = await getPendingConsent(deviceCode, { finalizeReview: true, subjectId: "owner_local" }); + assert.ok(typeof reviewed?.reviewRevision === "string"); + const approved = await approveGrant(deviceCode, "owner_local", { + approval_review_revision: reviewed?.reviewRevision, + }); + assert.deepEqual((approved.grant.streams as unknown as ResolvedStream[])[0]?.instance_ids, [connectorInstanceId]); + + await registerConnector(customCoreSourceManifest("custom_core_source_b", sourceId)); + await assert.rejects( + () => + initiateGrant({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/custom_source_test", + source: { id: sourceId, kind: "connector" }, + streams: [{ name: "items" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: driver.getRegisteredClientId(), + }), + MULTIPLE_LOCAL_BINDINGS_RE + ); + } finally { + await driver.teardown(); + } +}); + +test("provider-native grant snapshots its trusted declaration and binds its local instance to the approving subject", async () => { + const driver = createSqliteConsentDeviceAuthDriver(); + const nativeManifest = loadNativeManifest(); + const sourceId = nativeManifest.provider_id as string; + await driver.setup(); + configureNativeManifest(nativeManifest); + try { + const started = await initiateGrant( + { + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/financial_planning", + source: { id: sourceId, kind: "provider_native" }, + streams: [{ name: "pay_statements" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: driver.getRegisteredClientId(), + }, + { nativeManifest, nativeManifestMode: "local_operator_provisioning" } + ); + const deviceCode = parsePendingConsentRequestUri(started.request_uri); + assert.ok(deviceCode); + const pendingRow = getDb() + .prepare("SELECT params_json FROM pending_consents WHERE device_code = ?") + .get(deviceCode) as { params_json: string }; + const pending = JSON.parse(pendingRow.params_json) as PendingPayload; + assert.equal(pending.source_declaration_snapshot.declaration_version, "reference.native-config.northstar-hr.v1"); + assert.deepEqual(pending.source_declaration_snapshot.declaration.source, { + id: sourceId, + kind: "provider_native", + }); + assert.deepEqual(pending.source_declaration_snapshot.declaration.publisher, { + id: "https://pdpp.dev/reference-implementation", + }); + assert.equal(pending.source_declaration_snapshot.accepted_revision_reference, undefined); + assert.deepEqual(pending.source_declaration_snapshot.resource_authority, { + status: "local_operator_provisioned", + }); + assert.deepEqual(pending.source_declaration_snapshot.publisher_attribution, { + id: "https://pdpp.dev/reference-implementation", + status: "unverified", + }); + + const nativeOwnerId = "owner_native_alice"; + const reviewed = await getPendingConsent(deviceCode, { + finalizeReview: true, + nativeManifest, + subjectId: nativeOwnerId, + }); + assert.ok(typeof reviewed?.reviewRevision === "string"); + const reviewedRow = getDb() + .prepare("SELECT approval_review_json FROM pending_consents WHERE device_code = ?") + .get(deviceCode) as { approval_review_json: string }; + const reviewedArtifact = JSON.parse(reviewedRow.approval_review_json) as { + source_declaration: Record<string, unknown>; + }; + assert.deepEqual(reviewedArtifact.source_declaration.resource_authority, { + status: "local_operator_provisioned", + }); + assert.equal(reviewedArtifact.source_declaration.accepted_revision_reference, undefined); + const approved = await approveGrant(deviceCode, nativeOwnerId, { + approval_review_revision: reviewed?.reviewRevision, + nativeManifest, + }); + assert.deepEqual(approved.grant.source_declaration, { version: "reference.native-config.northstar-hr.v1" }); + const [issuedStream] = approved.grant.streams as unknown as ResolvedStream[]; + assert.ok(issuedStream); + assert.deepEqual(issuedStream.instance_ids, [ + makeDefaultAccountConnectorInstanceId( + nativeOwnerId, + (nativeManifest.storage_binding as { connector_id: string }).connector_id + ), + ]); + + const tokenState = await introspect(approved.token); + assert.equal(tokenState.active, true); + assert.deepEqual((tokenState.grant as { source?: unknown } | undefined)?.source, { + id: sourceId, + kind: "provider_native", + }); + assert.deepEqual( + ((tokenState.grant as { streams?: ResolvedStream[] } | undefined)?.streams ?? [])[0]?.instance_ids, + issuedStream.instance_ids + ); + + const explicitStarted = await initiateGrant( + { + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/financial_planning", + source: { id: sourceId, kind: "provider_native" }, + streams: [{ instance_ids: issuedStream.instance_ids, name: "pay_statements" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: driver.getRegisteredClientId(), + }, + { nativeManifest, nativeManifestMode: "local_operator_provisioning" } + ); + assert.ok(parsePendingConsentRequestUri(explicitStarted.request_uri)); + + const forgedNativeStarted = await initiateGrant( + { + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/financial_planning", + source: { id: sourceId, kind: "provider_native" }, + streams: [{ instance_ids: ["forged-native-instance"], name: "pay_statements" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: driver.getRegisteredClientId(), + }, + { nativeManifest, nativeManifestMode: "local_operator_provisioning" } + ); + const forgedNativeDeviceCode = parsePendingConsentRequestUri(forgedNativeStarted.request_uri); + assert.ok(forgedNativeDeviceCode); + await assert.rejects( + () => getPendingConsent(forgedNativeDeviceCode, { nativeManifest, subjectId: nativeOwnerId }), + INVALID_NATIVE_INSTANCE_RE + ); + } finally { + configureNativeManifest(null); + await driver.teardown(); + } +}); + +test("grant approval requires an existing unambiguous instance while staging closes selection values", async () => { + const driver = createSqliteConsentDeviceAuthDriver(); + await driver.setup(); + try { + const noInstanceStarted = await driver.startPendingConsent(); + const noInstanceDeviceCode = parsePendingConsentRequestUri(noInstanceStarted.request_uri); + assert.ok(noInstanceDeviceCode); + await assert.rejects( + () => getPendingConsent(noInstanceDeviceCode, { subjectId: "owner_local" }), + NO_ACTIVE_INSTANCE_RE + ); + + await seedActiveSpotifyInstance("cin_spotify_a", "a@example.com"); + await seedActiveSpotifyInstance("cin_spotify_b", "b@example.com"); + const multipleInstancesStarted = await driver.startPendingConsent(); + const multipleInstancesDeviceCode = parsePendingConsentRequestUri(multipleInstancesStarted.request_uri); + assert.ok(multipleInstancesDeviceCode); + await assert.rejects( + () => getPendingConsent(multipleInstancesDeviceCode, { subjectId: "owner_local" }), + MULTIPLE_ACTIVE_INSTANCES_RE + ); + + const source = { id: driver.getRegisteredConnectorId(), kind: "connector" }; + const base = { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personalization", + source, + streams: [{ instance_ids: ["cin_spotify_a"], name: "top_artists" }], + type: "https://pdpp.dev/data-access", + }; + const explicitlyBound = await initiateGrant({ + authorization_details: [base], + client_id: driver.getRegisteredClientId(), + }); + assert.ok(parsePendingConsentRequestUri(explicitlyBound.request_uri)); + + const { purpose_code: _purposeCode, ...withoutPurpose } = base; + await assert.rejects( + () => + initiateGrant({ + authorization_details: [withoutPurpose], + client_id: driver.getRegisteredClientId(), + }), + PURPOSE_CODE_RE + ); + await assert.rejects( + () => + initiateGrant({ + authorization_details: [ + { + ...base, + streams: [ + { + instance_ids: ["cin_spotify_a"], + name: "top_artists", + time_range: { since: "2026-02-01T00:00:00Z", until: "2026-01-01T00:00:00Z" }, + }, + ], + }, + ], + client_id: driver.getRegisteredClientId(), + }), + INVALID_TIME_RANGE_RE + ); + + const manifestRow = getDb().prepare("SELECT manifest FROM connectors WHERE connector_id = ?").get("spotify") as { + manifest: string; + }; + const compoundManifest = JSON.parse(manifestRow.manifest) as Record<string, unknown>; + const streams = compoundManifest.streams as Record<string, unknown>[]; + const topArtists = streams.find((stream) => stream.name === "top_artists"); + assert.ok(topArtists); + topArtists.primary_key = ["id", "name"]; + const declarationProbeStarted = await driver.startPendingConsent({ + streams: [{ instance_ids: ["cin_spotify_a"], name: "top_artists" }], + }); + const declarationProbeDeviceCode = parsePendingConsentRequestUri(declarationProbeStarted.request_uri); + assert.ok(declarationProbeDeviceCode); + const declarationProbeRow = getDb() + .prepare("SELECT params_json FROM pending_consents WHERE device_code = ?") + .get(declarationProbeDeviceCode) as { params_json: string }; + const declarationProbe = JSON.parse(declarationProbeRow.params_json) as PendingPayload; + compoundManifest.source_declaration = { + ...declarationProbe.source_declaration_snapshot.declaration, + streams: declarationProbe.source_declaration_snapshot.declaration.streams.map((stream) => + stream.name === "top_artists" ? { ...stream, primary_key: ["id", "name"] } : stream + ), + }; + await registerConnector(compoundManifest); + await assert.rejects( + () => + initiateGrant({ + authorization_details: [ + { + ...base, + streams: [ + { + instance_ids: ["cin_spotify_a"], + name: "top_artists", + resources: ['["artist-1"]'], + }, + ], + }, + ], + client_id: driver.getRegisteredClientId(), + }), + COMPOUND_RESOURCE_RE + ); + } finally { + await driver.teardown(); + } +}); + +test("private hosted wildcard expansion preserves the chosen instance across every stream", async () => { + const driver = createSqliteConsentDeviceAuthDriver(); + await driver.setup(); + try { + await seedActiveSpotifyInstance("cin_spotify_work", "work@example.com"); + await seedActiveSpotifyInstance("cin_spotify_personal", "personal@example.com"); + const result = await createHostedMcpGrantPackage({ + authorizationDetails: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personal_ai_assistant", + source: { id: driver.getRegisteredConnectorId(), kind: "connector" }, + streams: [{ instance_ids: ["cin_spotify_work"], name: "*" }], + type: "https://pdpp.dev/data-access", + }, + ], + clientId: driver.getRegisteredClientId(), + connectionIds: ["cin_spotify_work"], + storageBindings: [{ connector_id: "spotify" }], + }); + const childGrants = result.child_grants as Array<{ grant: { streams: ResolvedStream[] } }>; + assert.equal(childGrants.length, 1); + const [childGrant] = childGrants; + assert.ok(childGrant); + assert.ok(childGrant.grant.streams.length); + for (const stream of childGrant.grant.streams) { + assert.deepEqual(stream.instance_ids, ["cin_spotify_work"]); + assert.equal("connection_id" in stream, false); + } + + const packageId = result.package_id as string; + const packageRow = getDb() + .prepare("SELECT package_json FROM grant_packages WHERE package_id = ?") + .get(packageId) as { package_json: string }; + assert.equal((await introspect(result.token)).active, true); + getDb().prepare("UPDATE tokens SET subject_id = ? WHERE token_id = ?").run("owner_tampered", result.token); + assert.equal((await introspect(result.token)).active, false, "package token binding mismatch must fail closed"); + getDb().prepare("UPDATE tokens SET subject_id = ? WHERE token_id = ?").run("owner_local", result.token); + const memberRow = getDb() + .prepare( + `SELECT gm.grant_id, gm.token_id, g.grant_json + FROM grant_package_members gm + JOIN grants g ON g.grant_id = gm.grant_id + WHERE gm.package_id = ?` + ) + .get(packageId) as { grant_id: string; grant_json: string; token_id: string }; + const originalChildGrant = JSON.parse(memberRow.grant_json) as Record<string, unknown>; + const foreignChildGrant = structuredClone(originalChildGrant); + foreignChildGrant.subject = { id: "owner_foreign" }; + getDb() + .prepare("UPDATE grants SET subject_id = ?, grant_json = ? WHERE grant_id = ?") + .run("owner_foreign", JSON.stringify(foreignChildGrant), memberRow.grant_id); + getDb().prepare("UPDATE tokens SET subject_id = ? WHERE token_id = ?").run("owner_foreign", memberRow.token_id); + const foreignMemberAccess = (await getGrantPackageAccess(packageId)) as { members?: unknown[] } | null; + assert.deepEqual( + foreignMemberAccess?.members, + [], + "a valid child bound to another subject cannot be exposed through this package" + ); + getDb() + .prepare("UPDATE grants SET subject_id = ?, grant_json = ? WHERE grant_id = ?") + .run("owner_local", JSON.stringify(originalChildGrant), memberRow.grant_id); + getDb().prepare("UPDATE tokens SET subject_id = ? WHERE token_id = ?").run("owner_local", memberRow.token_id); + assert.equal( + ((await getGrantPackageAccess(packageId)) as { members: unknown[] }).members.length, + 1, + "restored child identity is active" + ); + const oldPackage = JSON.parse(packageRow.package_json) as Record<string, unknown>; + oldPackage.version = "reference.mcp_package.v1"; + getDb() + .prepare("UPDATE grant_packages SET package_json = ? WHERE package_id = ?") + .run(JSON.stringify(oldPackage), packageId); + assert.equal(await getGrantPackageAccess(packageId), null, "old package envelopes require fresh consent"); + const oldPackageToken = await introspect(result.token); + assert.equal(oldPackageToken.active, false, "old package tokens require fresh consent"); + assert.equal(oldPackageToken.inactive_reason, "package_invalid"); + + await assert.rejects( + () => + createHostedMcpGrantPackage({ + authorizationDetails: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/personal_ai_assistant", + source: { id: driver.getRegisteredConnectorId(), kind: "connector" }, + streams: [{ connection_id: "cin_spotify_work", name: "*" }], + type: "https://pdpp.dev/data-access", + }, + ], + clientId: driver.getRegisteredClientId(), + connectionIds: ["cin_spotify_work"], + storageBindings: [{ connector_id: "spotify" }], + }), + LEGACY_CONNECTION_ID_RE + ); + } finally { + await driver.teardown(); + } +}); diff --git a/reference-implementation/test/source-declaration-trust.test.ts b/reference-implementation/test/source-declaration-trust.test.ts new file mode 100644 index 000000000..75e545e18 --- /dev/null +++ b/reference-implementation/test/source-declaration-trust.test.ts @@ -0,0 +1,1015 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import tls from "node:tls"; +// biome-ignore lint/correctness/noUnresolvedImports: Node and TypeScript resolve this declared runtime dependency. +import Database from "better-sqlite3"; +import { Pool } from "pg"; +import { + createLiveDeclarationRetrievalDependencies, + createPinnedDeclarationFetch, +} from "../server/source-declaration-trust/live-retrieval.ts"; +import { retrieveSourceDeclaration } from "../server/source-declaration-trust/retrieval.ts"; +import { + acceptedRevisionEvidenceReference, + createPostgresAcceptedSourceDeclarationRevisionStore, + createSqliteAcceptedSourceDeclarationRevisionStore, +} from "../server/source-declaration-trust/revision-store.ts"; +import { + getAcceptedProviderNativeDeclarationRevision, + retrieveAndAcceptProviderNativeDeclaration, +} from "../server/source-declaration-trust/service.ts"; +import { dedicatedPostgresTestUrl } from "./helpers/dedicated-postgres-test-url.ts"; +import { withTemporaryPostgresDatabase } from "./helpers/postgres-temp-database.ts"; + +const POINTER = "https://declarations.example.test/current.json"; +const RESOURCE = "https://resource.example.test/owner/example"; +const AUTHORITY_BINDING_RE = /authority binding/; +const FINGERPRINT_MISMATCH_RE = /fingerprint mismatch/; +const UNIQUE_CONSTRAINT_VIOLATION = /duplicate key value violates unique constraint/; +const VALID_DECLARATION = { + declaration_version: "opaque:a", + display: { name: "Example" }, + protocol_version: "0.1.0", + publisher: { id: "https://publisher.example.test" }, + source: { id: RESOURCE, kind: "provider_native" }, + streams: [], +}; + +const policy = { maxAddresses: 4, maxBytes: 8192, maxRedirects: 1, timeoutMs: 1000 }; + +function streamBody(value: string): ReadableStream<Uint8Array> { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(value)); + controller.close(); + }, + }); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function validDeclaration(value: unknown) { + const declaration = value as typeof VALID_DECLARATION; + return declaration.source.id === RESOURCE && typeof declaration.declaration_version === "string" + ? { declaration, ok: true as const } + : { ok: false as const }; +} + +test("declaration retrieval binds every hop to fresh, policy-approved DNS answers and omits credentials", async () => { + const dnsLookups: string[] = []; + let redirectBodyCancelled = false; + const connections: Array<{ + addresses: readonly string[]; + credentials: string; + maxBytes: number; + redirect: string; + url: string; + }> = []; + const result = await retrieveSourceDeclaration({ acceptedPointer: POINTER, expectedSourceId: RESOURCE }, policy, { + allowsUrl: ({ acceptedPointer, targetUrl }) => + targetUrl === acceptedPointer || targetUrl === "https://cdn.example.test/revision.json", + fetch(request) { + connections.push({ + addresses: request.validatedAddresses, + credentials: request.credentials, + maxBytes: request.maxBytes, + redirect: request.redirect, + url: request.url, + }); + if (request.url === POINTER) { + return Promise.resolve({ + body: new ReadableStream({ + cancel() { + redirectBodyCancelled = true; + }, + }), + headers: { Location: "https://cdn.example.test/revision.json" }, + status: 302, + }); + } + return Promise.resolve({ body: streamBody(JSON.stringify(VALID_DECLARATION)), status: 200 }); + }, + resolveDns(hostname) { + dnsLookups.push(hostname); + return Promise.resolve(hostname === "declarations.example.test" ? ["203.0.113.5"] : ["2001:db8::5"]); + }, + validateAddress: ({ address, hostname }) => + (hostname === "declarations.example.test" && address === "203.0.113.5") || + (hostname === "cdn.example.test" && address === "2001:db8::5"), + validateDeclaration: validDeclaration, + }); + + assert.deepEqual(result, { + ok: true, + value: { declaration: VALID_DECLARATION, finalUrl: "https://cdn.example.test/revision.json" }, + }); + assert.deepEqual(dnsLookups, ["declarations.example.test", "cdn.example.test"]); + assert.deepEqual(connections, [ + { addresses: ["203.0.113.5"], credentials: "omit", maxBytes: 8192, redirect: "manual", url: POINTER }, + { + addresses: ["2001:db8::5"], + credentials: "omit", + maxBytes: 8192, + redirect: "manual", + url: "https://cdn.example.test/revision.json", + }, + ]); + assert.equal(redirectBodyCancelled, true, "redirect body is canceled before the next hop"); +}); + +test("live declaration adapter creates a fresh pinned dispatcher for each redirect hop and closes it with the response", async () => { + const dispatchers: Array<{ addresses: readonly string[]; closed: number }> = []; + const requests: Array<{ credentials: string; method: string | undefined; redirect: string; url: string }> = []; + const dependencies = createLiveDeclarationRetrievalDependencies({ + allowsUrl: ({ acceptedPointer, targetUrl }) => + targetUrl === acceptedPointer || targetUrl === "https://cdn.example.test/revision.json", + dnsLookupImpl: async (hostname) => [ + { address: hostname === "declarations.example.test" ? "127.0.0.2" : "127.0.0.3" }, + ], + fetchImpl: (url, init) => { + requests.push({ + credentials: String(init?.credentials), + method: init?.method, + redirect: String(init?.redirect), + url: String(url), + }); + if (String(url) === POINTER) { + return Promise.resolve({ + body: streamBody(""), + headers: new Headers({ Location: "https://cdn.example.test/revision.json" }), + status: 302, + }); + } + return Promise.resolve({ + body: streamBody(JSON.stringify(VALID_DECLARATION)), + headers: new Headers(), + status: 200, + }); + }, + pinnedDispatcherFactory: (addresses) => { + const dispatcher = { addresses: [...addresses], closed: 0 }; + dispatchers.push(dispatcher); + return { + close: () => { + dispatcher.closed += 1; + return Promise.resolve(); + }, + }; + }, + validateAddress: () => true, + validateDeclaration: validDeclaration, + }); + + const result = await retrieveSourceDeclaration( + { acceptedPointer: POINTER, expectedSourceId: RESOURCE }, + policy, + dependencies + ); + + assert.equal(result.ok, true); + assert.deepEqual( + dispatchers.map(({ addresses }) => addresses), + [["127.0.0.2"], ["127.0.0.3"]], + "each redirect hop gets a dispatcher pinned to that hop's fresh DNS answer" + ); + assert.deepEqual(requests, [ + { credentials: "omit", method: "GET", redirect: "manual", url: POINTER }, + { + credentials: "omit", + method: "GET", + redirect: "manual", + url: "https://cdn.example.test/revision.json", + }, + ]); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual( + dispatchers.map(({ closed }) => closed), + [1, 1], + "the transport dispatcher closes after redirect cancellation and response EOF" + ); +}); + +test("live declaration adapter dials only the validated literal while preserving TLS authority", async () => { + // `.invalid` cannot resolve. Reaching tls.connect therefore proves that the + // shared pinned dispatcher dialed the injected, accepted literal rather + // than letting undici perform a second hostname lookup. + const originalTlsConnect = tls.connect; + const dialedHosts: Array<{ host: string | undefined; servername: string | undefined }> = []; + tls.connect = ((options: { host?: string; servername?: string }, ...rest: unknown[]) => { + dialedHosts.push({ host: options.host, servername: options.servername }); + return (originalTlsConnect as (...args: unknown[]) => ReturnType<typeof tls.connect>).apply(tls, [ + options, + ...rest, + ]); + }) as typeof tls.connect; + + const pointer = "https://declaration-rebind-proof.invalid/current.json"; + try { + const result = await retrieveSourceDeclaration( + { acceptedPointer: pointer, expectedSourceId: RESOURCE }, + { ...policy, timeoutMs: 2000 }, + createLiveDeclarationRetrievalDependencies({ + dnsLookupImpl: async () => [{ address: "127.0.0.1" }], + validateAddress: () => true, + validateDeclaration: validDeclaration, + }) + ); + assert.deepEqual(result, { ok: false, reason: "http_error" }); + assert.equal(dialedHosts.length, 1, "the bounded connector attempts the one validated address once"); + assert.deepEqual(dialedHosts[0], { host: "127.0.0.1", servername: "declaration-rebind-proof.invalid" }); + } finally { + tls.connect = originalTlsConnect; + } +}); + +test("live declaration adapter rejects direct empty or oversized connector sets", async () => { + let fetches = 0; + const fetch = createPinnedDeclarationFetch({ + fetchImpl: () => { + fetches += 1; + return Promise.resolve({ body: streamBody(""), headers: new Headers(), status: 200 }); + }, + }); + const request = { + credentials: "omit" as const, + maxBytes: 64, + redirect: "manual" as const, + signal: new AbortController().signal, + url: POINTER, + }; + await assert.rejects(() => fetch({ ...request, validatedAddresses: [] })); + await assert.rejects(() => + fetch({ ...request, validatedAddresses: Array.from({ length: 9 }, (_, index) => `127.0.0.${index + 1}`) }) + ); + assert.equal(fetches, 0, "the actual socket adapter fails closed instead of truncating direct callers' address sets"); +}); + +test("live declaration adapter closes a pinned dispatcher when fetch fails", async () => { + let closes = 0; + const fetch = createPinnedDeclarationFetch({ + fetchImpl: () => Promise.reject(new Error("connect failed")), + pinnedDispatcherFactory: () => ({ + close: () => { + closes += 1; + return Promise.resolve(); + }, + }), + }); + await assert.rejects(() => + fetch({ + credentials: "omit", + maxBytes: 64, + redirect: "manual", + signal: new AbortController().signal, + url: POINTER, + validatedAddresses: ["127.0.0.1"], + }) + ); + assert.equal(closes, 1); +}); + +test("a late fetch response after timeout is canceled and closes its pinned dispatcher", async () => { + let bodyCancelled = false; + let dispatcherCloses = 0; + let markFetchStarted!: () => void; + let resolveFetch!: (response: { body: ReadableStream<Uint8Array>; headers: Headers; status: number }) => void; + const fetchStarted = new Promise<void>((resolve) => { + markFetchStarted = resolve; + }); + const lateResponse = new Promise<{ body: ReadableStream<Uint8Array>; headers: Headers; status: number }>( + (resolve) => { + resolveFetch = resolve; + } + ); + const result = retrieveSourceDeclaration( + { acceptedPointer: POINTER, expectedSourceId: RESOURCE }, + { ...policy, timeoutMs: 25 }, + { + fetch: createPinnedDeclarationFetch({ + fetchImpl: () => { + markFetchStarted(); + return lateResponse; + }, + pinnedDispatcherFactory: () => ({ + close: () => { + dispatcherCloses += 1; + return Promise.resolve(); + }, + }), + }), + resolveDns: () => Promise.resolve(["127.0.0.1"]), + validateAddress: () => true, + validateDeclaration: validDeclaration, + } + ); + await fetchStarted; + assert.deepEqual(await result, { ok: false, reason: "timeout" }); + resolveFetch({ + body: new ReadableStream({ + cancel() { + bodyCancelled = true; + }, + }), + headers: new Headers(), + status: 200, + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(bodyCancelled, true); + assert.equal(dispatcherCloses, 1); +}); + +test("live declaration adapter cancels an over-limit stream before retaining it", async () => { + let cancelled = false; + const fetch = createPinnedDeclarationFetch({ + fetchImpl: () => + Promise.resolve({ + body: new ReadableStream({ + cancel() { + cancelled = true; + }, + start(controller) { + controller.enqueue(new TextEncoder().encode("12345")); + }, + }), + headers: new Headers(), + status: 200, + }), + }); + const response = await fetch({ + credentials: "omit", + maxBytes: 4, + redirect: "manual", + signal: new AbortController().signal, + url: POINTER, + validatedAddresses: ["127.0.0.1"], + }); + await assert.rejects(() => response.body.getReader().read(), { name: "DeclarationResponseTooLargeError" }); + assert.equal(cancelled, true); +}); + +test("declaration retrieval fails closed before connecting when any fresh answer or redirect is not approved", async () => { + let fetchCount = 0; + const blockedAddress = await retrieveSourceDeclaration( + { acceptedPointer: POINTER, expectedSourceId: RESOURCE }, + policy, + { + fetch: () => { + fetchCount += 1; + return Promise.resolve({ body: streamBody(JSON.stringify(VALID_DECLARATION)), status: 200 }); + }, + resolveDns: () => Promise.resolve(["203.0.113.4", "127.0.0.1"]), + validateAddress: ({ address }) => address !== "127.0.0.1", + validateDeclaration: validDeclaration, + } + ); + assert.deepEqual(blockedAddress, { ok: false, reason: "address_rejected" }); + assert.equal(fetchCount, 0); + + const excessiveAnswers = await retrieveSourceDeclaration( + { acceptedPointer: POINTER, expectedSourceId: RESOURCE }, + policy, + { + fetch: () => { + fetchCount += 1; + return Promise.resolve({ body: streamBody(JSON.stringify(VALID_DECLARATION)), status: 200 }); + }, + resolveDns: () => Promise.resolve(["203.0.113.1", "203.0.113.2", "203.0.113.3", "203.0.113.4", "203.0.113.5"]), + validateAddress: () => Promise.resolve(true), + validateDeclaration: validDeclaration, + } + ); + assert.deepEqual(excessiveAnswers, { ok: false, reason: "address_rejected" }); + assert.equal(fetchCount, 0, "DNS answer bound prevents a connection attempt"); + + const blockedRedirect = await retrieveSourceDeclaration( + { acceptedPointer: POINTER, expectedSourceId: RESOURCE }, + policy, + { + allowsUrl: ({ acceptedPointer, targetUrl }) => targetUrl === acceptedPointer, + fetch: () => + Promise.resolve({ + body: streamBody(""), + headers: { location: "https://other.example.test/declaration.json" }, + status: 302, + }), + resolveDns: () => Promise.resolve(["203.0.113.4"]), + validateAddress: () => Promise.resolve(true), + validateDeclaration: validDeclaration, + } + ); + assert.deepEqual(blockedRedirect, { ok: false, reason: "invalid_redirect" }); +}); + +test("declaration retrieval rejects oversized bodies and source IDs without fetching schemas", async () => { + let validations = 0; + const oversized = await retrieveSourceDeclaration( + { acceptedPointer: POINTER, expectedSourceId: RESOURCE }, + { ...policy, maxBytes: 4 }, + { + fetch: () => Promise.resolve({ body: streamBody(JSON.stringify(VALID_DECLARATION)), status: 200 }), + resolveDns: () => Promise.resolve(["203.0.113.4"]), + validateAddress: () => Promise.resolve(true), + validateDeclaration: () => { + validations += 1; + return validDeclaration(VALID_DECLARATION); + }, + } + ); + assert.deepEqual(oversized, { ok: false, reason: "body_too_large" }); + assert.equal(validations, 0, "no declaration validation or schema retrieval occurs after a byte-bound failure"); + + const mismatch = await retrieveSourceDeclaration({ acceptedPointer: POINTER, expectedSourceId: RESOURCE }, policy, { + fetch: () => + Promise.resolve({ + body: streamBody( + JSON.stringify({ + ...VALID_DECLARATION, + source: { ...VALID_DECLARATION.source, id: "https://other.example.test" }, + }) + ), + status: 200, + }), + resolveDns: () => Promise.resolve(["203.0.113.4"]), + validateAddress: () => Promise.resolve(true), + validateDeclaration: (value) => ({ declaration: value as typeof VALID_DECLARATION, ok: true }), + }); + assert.deepEqual(mismatch, { ok: false, reason: "source_mismatch" }); + + const validatorFailure = await retrieveSourceDeclaration( + { acceptedPointer: POINTER, expectedSourceId: RESOURCE }, + policy, + { + fetch: () => Promise.resolve({ body: streamBody(JSON.stringify(VALID_DECLARATION)), status: 200 }), + resolveDns: () => Promise.resolve(["203.0.113.4"]), + validateAddress: () => Promise.resolve(true), + validateDeclaration: () => { + throw new Error("hostile declaration"); + }, + } + ); + assert.deepEqual(validatorFailure, { ok: false, reason: "declaration_invalid" }); +}); + +test("declaration retrieval cancels a response stream at the configured byte limit", async () => { + let cancelled = false; + const result = await retrieveSourceDeclaration( + { acceptedPointer: POINTER, expectedSourceId: RESOURCE }, + { ...policy, maxBytes: 4 }, + { + fetch: () => + Promise.resolve({ + body: new ReadableStream({ + cancel() { + cancelled = true; + }, + start(controller) { + controller.enqueue(new TextEncoder().encode("12345")); + }, + }), + status: 200, + }), + resolveDns: () => Promise.resolve(["203.0.113.4"]), + validateAddress: () => Promise.resolve(true), + validateDeclaration: validDeclaration, + } + ); + assert.deepEqual(result, { ok: false, reason: "body_too_large" }); + assert.equal(cancelled, true); +}); + +test("declaration retrieval turns body-read failures into typed fail-closed outcomes", async () => { + const dependencies = { + resolveDns: () => Promise.resolve(["203.0.113.4"]), + validateAddress: () => Promise.resolve(true), + validateDeclaration: validDeclaration, + }; + const readError = await retrieveSourceDeclaration({ acceptedPointer: POINTER, expectedSourceId: RESOURCE }, policy, { + ...dependencies, + fetch: () => + Promise.resolve({ + body: new ReadableStream({ + start(controller) { + controller.error(new Error("peer ended stream")); + }, + }), + status: 200, + }), + }); + assert.deepEqual(readError, { ok: false, reason: "http_error" }); + + let timedOutBodyCancelled = false; + const readTimeout = await retrieveSourceDeclaration( + { acceptedPointer: POINTER, expectedSourceId: RESOURCE }, + { ...policy, timeoutMs: 1 }, + { + ...dependencies, + fetch: () => + Promise.resolve({ + body: new ReadableStream({ + cancel() { + timedOutBodyCancelled = true; + }, + }), + status: 200, + }), + } + ); + assert.deepEqual(readTimeout, { ok: false, reason: "timeout" }); + assert.equal(timedOutBodyCancelled, true, "deadline cancels a body stream even when fetch ignores AbortSignal"); +}); + +test("declaration retrieval rejects malformed UTF-8 and cancels non-success bodies", async () => { + let errorBodyCancelled = false; + const invalidUtf8 = await retrieveSourceDeclaration( + { acceptedPointer: POINTER, expectedSourceId: RESOURCE }, + policy, + { + fetch: () => + Promise.resolve({ + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0xff])); + controller.close(); + }, + }), + status: 200, + }), + resolveDns: () => Promise.resolve(["203.0.113.4"]), + validateAddress: () => Promise.resolve(true), + validateDeclaration: validDeclaration, + } + ); + assert.deepEqual(invalidUtf8, { ok: false, reason: "declaration_invalid" }); + + const httpError = await retrieveSourceDeclaration({ acceptedPointer: POINTER, expectedSourceId: RESOURCE }, policy, { + fetch: () => + Promise.resolve({ + body: new ReadableStream({ + cancel() { + errorBodyCancelled = true; + }, + }), + status: 500, + }), + resolveDns: () => Promise.resolve(["203.0.113.4"]), + validateAddress: () => Promise.resolve(true), + validateDeclaration: validDeclaration, + }); + assert.deepEqual(httpError, { ok: false, reason: "http_error" }); + assert.equal(errorBodyCancelled, true); +}); + +test("declaration retrieval bounds DNS work by the configured deadline", async () => { + const result = await retrieveSourceDeclaration( + { acceptedPointer: POINTER, expectedSourceId: RESOURCE }, + { ...policy, timeoutMs: 1 }, + { + fetch: () => Promise.resolve({ body: streamBody(JSON.stringify(VALID_DECLARATION)), status: 200 }), + resolveDns: () => + new Promise<readonly string[]>(() => { + // Deliberately unresolved: the retriever, not DNS, owns the bound. + }), + validateAddress: () => Promise.resolve(true), + validateDeclaration: validDeclaration, + } + ); + assert.deepEqual(result, { ok: false, reason: "timeout" }); +}); + +function immutableRevisionCases(store: { + accept: (input: { + authorityBinding: string; + declarationVersion: string; + parsedDeclaration: unknown; + sourceId: string; + }) => Promise<unknown>; + getByReference: (acceptedRevisionReference: string) => Promise<unknown>; +}) { + const key = { authorityBinding: POINTER, declarationVersion: "opaque:1", sourceId: RESOURCE }; + const expectedReference = acceptedRevisionEvidenceReference(key); + const sameParsedContentDifferentTextOrder = JSON.parse('{"streams":["a"],"display":{"name":"First"}}'); + return Promise.resolve().then(async () => { + assert.equal(await store.getByReference(expectedReference), null); + assert.deepEqual(await store.accept({ ...key, parsedDeclaration: sameParsedContentDifferentTextOrder }), { + accepted: true, + acceptedRevisionReference: expectedReference, + existing: false, + }); + assert.deepEqual(await store.getByReference(expectedReference), { + acceptedRevisionReference: expectedReference, + authorityBinding: key.authorityBinding, + declarationVersion: key.declarationVersion, + parsedDeclaration: { display: { name: "First" }, streams: ["a"] }, + sourceId: key.sourceId, + }); + assert.deepEqual( + await store.accept({ ...key, parsedDeclaration: { display: { name: "First" }, streams: ["a"] } }), + { + accepted: true, + acceptedRevisionReference: expectedReference, + existing: true, + } + ); + assert.deepEqual( + await store.accept({ ...key, parsedDeclaration: { display: { name: "Changed" }, streams: ["a"] } }), + { + accepted: false, + reason: "equivocation", + } + ); + assert.deepEqual( + await store.accept({ + ...key, + declarationVersion: "not-sortable:prior", + parsedDeclaration: { display: { name: "Prior" } }, + }), + { + accepted: true, + acceptedRevisionReference: acceptedRevisionEvidenceReference({ + ...key, + declarationVersion: "not-sortable:prior", + }), + existing: false, + }, + "versions are opaque keys; storage performs no ordering inference" + ); + assert.notEqual( + expectedReference, + acceptedRevisionEvidenceReference({ ...key, authorityBinding: "metadata:https://other.example.test" }), + "accepted revision evidence references are bound to the accepted authority" + ); + assert.equal( + await store.getByReference( + acceptedRevisionEvidenceReference({ ...key, authorityBinding: "metadata:https://other.example.test" }) + ), + null + ); + }); +} + +test("SQLite accepted revisions preserve parsed-content identity and reject equivocation", async () => { + const database = new Database(":memory:"); + try { + await immutableRevisionCases(createSqliteAcceptedSourceDeclarationRevisionStore(database)); + } finally { + database.close(); + } +}); + +test("SQLite accepted revision lookup fails closed when stored evidence is tampered", async () => { + const database = new Database(":memory:"); + const key = { authorityBinding: POINTER, declarationVersion: "opaque:tamper", sourceId: RESOURCE }; + const store = createSqliteAcceptedSourceDeclarationRevisionStore(database); + const expectedReference = acceptedRevisionEvidenceReference(key); + try { + await store.accept({ ...key, parsedDeclaration: { display: { name: "Original" }, streams: ["a"] } }); + database + .prepare("UPDATE accepted_source_declaration_revisions SET source_id = ? WHERE accepted_revision_reference = ?") + .run("https://resource.example.test/owner/changed", expectedReference); + await assert.rejects(store.getByReference(expectedReference), AUTHORITY_BINDING_RE); + + database + .prepare("UPDATE accepted_source_declaration_revisions SET source_id = ? WHERE accepted_revision_reference = ?") + .run(key.sourceId, expectedReference); + database + .prepare( + "UPDATE accepted_source_declaration_revisions SET canonical_content = ? WHERE accepted_revision_reference = ?" + ) + .run('{"display":{"name":"Changed"},"streams":["a"]}', expectedReference); + await assert.rejects(store.getByReference(expectedReference), FINGERPRINT_MISMATCH_RE); + } finally { + database.close(); + } +}); + +test("SQLite accepted revision store migrates the prior table shape idempotently", async () => { + const database = new Database(":memory:"); + const key = { authorityBinding: POINTER, declarationVersion: "opaque:legacy", sourceId: RESOURCE }; + const canonicalContent = '{"display":{"name":"Legacy"},"streams":["a"]}'; + try { + database.exec(` + CREATE TABLE accepted_source_declaration_revisions ( + authority_binding TEXT NOT NULL, + source_id TEXT NOT NULL, + declaration_version TEXT NOT NULL, + canonical_content TEXT NOT NULL, + content_fingerprint TEXT NOT NULL, + PRIMARY KEY (authority_binding, source_id, declaration_version) + ); + `); + database + .prepare( + `INSERT INTO accepted_source_declaration_revisions + (authority_binding, source_id, declaration_version, canonical_content, content_fingerprint) + VALUES (?, ?, ?, ?, ?)` + ) + .run(key.authorityBinding, key.sourceId, key.declarationVersion, canonicalContent, sha256(canonicalContent)); + + const store = createSqliteAcceptedSourceDeclarationRevisionStore(database); + const expectedReference = acceptedRevisionEvidenceReference(key); + assert.deepEqual( + await store.accept({ ...key, parsedDeclaration: { display: { name: "Legacy" }, streams: ["a"] } }), + { + accepted: true, + acceptedRevisionReference: expectedReference, + existing: true, + } + ); + assert.deepEqual( + await store.accept({ ...key, parsedDeclaration: { display: { name: "Changed" }, streams: ["a"] } }), + { + accepted: false, + reason: "equivocation", + } + ); + const migratedReference = ( + database.prepare("SELECT accepted_revision_reference FROM accepted_source_declaration_revisions") as { + get: () => { accepted_revision_reference: string } | undefined; + } + ).get(); + assert.deepEqual(migratedReference, { accepted_revision_reference: expectedReference }); + const migratedColumns = ( + database.prepare("PRAGMA table_info(accepted_source_declaration_revisions)") as { + all: () => Array<{ name: string; notnull: number }>; + } + ).all(); + assert.equal(migratedColumns.find((row) => row.name === "accepted_revision_reference")?.notnull, 1); + + const idempotentStore = createSqliteAcceptedSourceDeclarationRevisionStore(database); + assert.deepEqual( + await idempotentStore.accept({ ...key, parsedDeclaration: { display: { name: "Legacy" }, streams: ["a"] } }), + { + accepted: true, + acceptedRevisionReference: expectedReference, + existing: true, + } + ); + } finally { + database.close(); + } +}); + +test("standalone trust service persists only a retrieved, source-matching declaration", async () => { + const database = new Database(":memory:"); + const revisionStore = createSqliteAcceptedSourceDeclarationRevisionStore(database); + try { + const result = await retrieveAndAcceptProviderNativeDeclaration( + { + acceptedPointer: POINTER, + authorityBinding: "metadata:https://resource.example.test", + expectedSourceId: RESOURCE, + }, + { + fetch: () => Promise.resolve({ body: streamBody(JSON.stringify(VALID_DECLARATION)), status: 200 }), + resolveDns: () => Promise.resolve(["203.0.113.4"]), + revisionStore, + validateAddress: () => Promise.resolve(true), + validateDeclaration: validDeclaration, + }, + policy + ); + assert.deepEqual(result, { + acceptedRevisionReference: acceptedRevisionEvidenceReference({ + authorityBinding: "metadata:https://resource.example.test", + declarationVersion: "opaque:a", + sourceId: RESOURCE, + }), + declarationVersion: "opaque:a", + finalUrl: POINTER, + ok: true, + }); + assert.equal(result.ok, true); + assert.deepEqual( + await getAcceptedProviderNativeDeclarationRevision( + { acceptedRevisionReference: result.acceptedRevisionReference }, + { revisionStore } + ), + { + acceptedRevisionReference: result.acceptedRevisionReference, + authorityBinding: "metadata:https://resource.example.test", + declarationVersion: "opaque:a", + parsedDeclaration: VALID_DECLARATION, + sourceId: RESOURCE, + } + ); + } finally { + database.close(); + } +}); + +test("standalone trust service rejects provider-native discovery when the declaration kind is not provider_native", async () => { + const database = new Database(":memory:"); + try { + const result = await retrieveAndAcceptProviderNativeDeclaration( + { + acceptedPointer: POINTER, + authorityBinding: "metadata:https://resource.example.test", + expectedSourceId: RESOURCE, + }, + { + fetch: () => + Promise.resolve({ + body: streamBody( + JSON.stringify({ + ...VALID_DECLARATION, + source: { ...VALID_DECLARATION.source, kind: "connector" }, + }) + ), + status: 200, + }), + resolveDns: () => Promise.resolve(["203.0.113.4"]), + revisionStore: createSqliteAcceptedSourceDeclarationRevisionStore(database), + validateAddress: () => Promise.resolve(true), + validateDeclaration: (value) => ({ declaration: value as typeof VALID_DECLARATION, ok: true }), + }, + policy + ); + assert.deepEqual(result, { ok: false, reason: "source_kind_mismatch" }); + } finally { + database.close(); + } +}); + +const POSTGRES_URL = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); +let postgresCounter = 0; + +if (POSTGRES_URL) { + test("PostgreSQL accepted revisions have the same immutable parsed-content behavior as SQLite", async () => { + postgresCounter += 1; + await withTemporaryPostgresDatabase( + { + connectionString: POSTGRES_URL, + databaseName: `pdpp_source_declaration_trust_${process.pid}_${postgresCounter}`, + }, + async (databaseUrl) => { + const pool = new Pool({ connectionString: databaseUrl }); + try { + await immutableRevisionCases(await createPostgresAcceptedSourceDeclarationRevisionStore(pool)); + } finally { + await pool.end(); + } + } + ); + }); + + test("PostgreSQL accepted revision store migrates the prior table shape idempotently", async () => { + postgresCounter += 1; + await withTemporaryPostgresDatabase( + { + connectionString: POSTGRES_URL, + databaseName: `pdpp_source_declaration_trust_${process.pid}_${postgresCounter}`, + }, + async (databaseUrl) => { + const pool = new Pool({ connectionString: databaseUrl }); + const key = { authorityBinding: POINTER, declarationVersion: "opaque:legacy", sourceId: RESOURCE }; + const canonicalContent = '{"display":{"name":"Legacy"},"streams":["a"]}'; + try { + await pool.query(` + CREATE TABLE accepted_source_declaration_revisions ( + authority_binding TEXT NOT NULL, + source_id TEXT NOT NULL, + declaration_version TEXT NOT NULL, + canonical_content TEXT NOT NULL, + content_fingerprint TEXT NOT NULL, + PRIMARY KEY (authority_binding, source_id, declaration_version) + ); + `); + await pool.query( + `INSERT INTO accepted_source_declaration_revisions + (authority_binding, source_id, declaration_version, canonical_content, content_fingerprint) + VALUES ($1, $2, $3, $4, $5)`, + [key.authorityBinding, key.sourceId, key.declarationVersion, canonicalContent, sha256(canonicalContent)] + ); + + const store = await createPostgresAcceptedSourceDeclarationRevisionStore(pool); + const expectedReference = acceptedRevisionEvidenceReference(key); + assert.deepEqual( + await store.accept({ ...key, parsedDeclaration: { display: { name: "Legacy" }, streams: ["a"] } }), + { + accepted: true, + acceptedRevisionReference: expectedReference, + existing: true, + } + ); + assert.deepEqual( + await store.accept({ ...key, parsedDeclaration: { display: { name: "Changed" }, streams: ["a"] } }), + { + accepted: false, + reason: "equivocation", + } + ); + assert.deepEqual( + (await pool.query("SELECT accepted_revision_reference FROM accepted_source_declaration_revisions")).rows, + [{ accepted_revision_reference: expectedReference }] + ); + const duplicateReferenceKey = { + authorityBinding: "metadata:https://duplicate.example.test", + declarationVersion: "opaque:legacy-duplicate", + sourceId: RESOURCE, + }; + await assert.rejects( + pool.query( + `INSERT INTO accepted_source_declaration_revisions + (authority_binding, source_id, declaration_version, accepted_revision_reference, canonical_content, content_fingerprint) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + duplicateReferenceKey.authorityBinding, + duplicateReferenceKey.sourceId, + duplicateReferenceKey.declarationVersion, + expectedReference, + canonicalContent, + sha256(canonicalContent), + ] + ), + UNIQUE_CONSTRAINT_VIOLATION + ); + assert.deepEqual( + ( + await pool.query( + `SELECT attnotnull + FROM pg_attribute + WHERE attrelid = 'accepted_source_declaration_revisions'::regclass + AND attname = 'accepted_revision_reference'` + ) + ).rows, + [{ attnotnull: true }] + ); + + const idempotentStore = await createPostgresAcceptedSourceDeclarationRevisionStore(pool); + assert.deepEqual( + await idempotentStore.accept({ + ...key, + parsedDeclaration: { display: { name: "Legacy" }, streams: ["a"] }, + }), + { + accepted: true, + acceptedRevisionReference: expectedReference, + existing: true, + } + ); + assert.deepEqual(await idempotentStore.getByReference(expectedReference), { + acceptedRevisionReference: expectedReference, + authorityBinding: key.authorityBinding, + declarationVersion: key.declarationVersion, + parsedDeclaration: { display: { name: "Legacy" }, streams: ["a"] }, + sourceId: key.sourceId, + }); + assert.equal( + await idempotentStore.getByReference( + acceptedRevisionEvidenceReference({ ...key, sourceId: "https://resource.example.test/other" }) + ), + null + ); + } finally { + await pool.end(); + } + } + ); + }); + + test("PostgreSQL accepted revision lookup fails closed when stored evidence is tampered", async () => { + postgresCounter += 1; + await withTemporaryPostgresDatabase( + { + connectionString: POSTGRES_URL, + databaseName: `pdpp_source_declaration_trust_${process.pid}_${postgresCounter}`, + }, + async (databaseUrl) => { + const pool = new Pool({ connectionString: databaseUrl }); + const key = { authorityBinding: POINTER, declarationVersion: "opaque:tamper", sourceId: RESOURCE }; + const expectedReference = acceptedRevisionEvidenceReference(key); + try { + const store = await createPostgresAcceptedSourceDeclarationRevisionStore(pool); + await store.accept({ ...key, parsedDeclaration: { display: { name: "Original" }, streams: ["a"] } }); + await pool.query( + "UPDATE accepted_source_declaration_revisions SET source_id = $1 WHERE accepted_revision_reference = $2", + ["https://resource.example.test/owner/changed", expectedReference] + ); + await assert.rejects(store.getByReference(expectedReference), AUTHORITY_BINDING_RE); + + await pool.query( + "UPDATE accepted_source_declaration_revisions SET source_id = $1 WHERE accepted_revision_reference = $2", + [key.sourceId, expectedReference] + ); + await pool.query( + "UPDATE accepted_source_declaration_revisions SET canonical_content = $1 WHERE accepted_revision_reference = $2", + ['{"display":{"name":"Changed"},"streams":["a"]}', expectedReference] + ); + await assert.rejects(store.getByReference(expectedReference), FINGERPRINT_MISMATCH_RE); + } finally { + await pool.end(); + } + } + ); + }); +} else { + test("PostgreSQL accepted revision parity (skipped: PDPP_TEST_POSTGRES_URL unset)", { skip: true }, () => { + // Environment-gated real backend proof. + }); +} diff --git a/reference-implementation/test/source-descriptor-pure.test.ts b/reference-implementation/test/source-descriptor-pure.test.ts index 1258a8583..e6c6ddb68 100644 --- a/reference-implementation/test/source-descriptor-pure.test.ts +++ b/reference-implementation/test/source-descriptor-pure.test.ts @@ -89,9 +89,9 @@ test("buildClientSourceDescriptor: grant.source wins over storage_binding", () = ); }); -test("buildClientSourceDescriptor: falls back to storage_binding connector when no grant.source", () => { +test("buildClientSourceDescriptor: does not expose storage_binding as public source identity", () => { const tokenInfo = { grant_storage_binding: { connector_id: "fallback-connector" } }; - assert.deepEqual(buildClientSourceDescriptor(tokenInfo), { id: "fallback-connector", kind: "connector" }); + assert.equal(buildClientSourceDescriptor(tokenInfo), null); }); test("buildClientSourceDescriptor: null when neither grant.source nor storage binding present", () => { diff --git a/reference-implementation/test/source-descriptor.test.ts b/reference-implementation/test/source-descriptor.test.ts index a8cfb8d8c..f485ac05d 100644 --- a/reference-implementation/test/source-descriptor.test.ts +++ b/reference-implementation/test/source-descriptor.test.ts @@ -70,10 +70,9 @@ test("buildClientSourceDescriptor prefers grant.source over the storage binding" assert.deepEqual(buildClientSourceDescriptor(tokenInfo), { id: "apple", kind: "provider_native" }); }); -test("buildClientSourceDescriptor falls back to the storage binding connector_id", () => { +test("buildClientSourceDescriptor never exposes an internal storage connector as public source identity", () => { const tokenInfo = { grant_storage_binding: { connector_id: "gmail" } }; - assert.deepEqual(buildClientSourceDescriptor(tokenInfo), { id: "gmail", kind: "connector" }); - // Nothing resolvable -> null. + assert.equal(buildClientSourceDescriptor(tokenInfo), null); assert.equal(buildClientSourceDescriptor({}), null); assert.equal(buildClientSourceDescriptor(null), null); }); @@ -87,10 +86,17 @@ test("resolveNativeManifest / resolveNativeStorageBinding read the injected nati assert.equal(resolveNativeStorageBinding({ nativeManifest: { provider_id: "apple" } }), null); }); -test("buildOwnerQuerySourceDescriptor prefers the native manifest provider over the query connector_id", () => { +test("buildOwnerQuerySourceDescriptor prefers the configured declaration source over the query connector_id", () => { const req = { query: { connector_id: "gmail" } }; - const opts = { nativeManifest: { provider_id: "apple" } }; - assert.deepEqual(buildOwnerQuerySourceDescriptor(req, opts), { id: "apple", kind: "provider_native" }); + const opts = { + nativeManifest: { + source_declaration: { source: { id: "https://apple.example/pdpp", kind: "provider_native" } }, + }, + }; + assert.deepEqual(buildOwnerQuerySourceDescriptor(req, opts), { + id: "https://apple.example/pdpp", + kind: "provider_native", + }); }); test("buildOwnerQuerySourceDescriptor canonicalizes a URL-shaped connector_id", () => { diff --git a/reference-implementation/test/source-field-name-contract-parity.test.ts b/reference-implementation/test/source-field-name-contract-parity.test.ts new file mode 100644 index 000000000..2e7cb02cf --- /dev/null +++ b/reference-implementation/test/source-field-name-contract-parity.test.ts @@ -0,0 +1,164 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * PR102 SourceDeclaration field references are non-empty strings that name + * literal top-level schema.properties keys. They are not JavaScript or SQL + * identifiers. These integration-focused regressions cover each local + * consumer in this lane with representative hyphen, dot, quote, and Unicode + * names. + */ + +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { reconcileDirtyDatasetSummaryRecordTimeBounds } from "../server/dataset-summary-read-model.ts"; +import { closeDb, getDb, initDb } from "../server/db.ts"; +import { computeIngestSemanticTime, getManifestConsentTimeField } from "../server/record-ingest-semantic-time.ts"; +import { collectRecordsTimelineEntries } from "../server/ref-control.ts"; + +const FIELD_NAMES = ["event-time", "occurred.at", 'said "when"', "時刻"] as const; +const AUTHORED_AT = "2026-02-03T04:05:06.000Z"; +const EMITTED_AT = "2026-03-04T05:06:07.000Z"; + +async function withTempDb<T>(fn: () => T | Promise<T>): Promise<T> { + const dir = mkdtempSync(join(tmpdir(), "pdpp-source-field-names-")); + try { + initDb(join(dir, "pdpp.sqlite")); + return await fn(); + } finally { + closeDb(); + rmSync(dir, { force: true, recursive: true }); + } +} + +function storeManifest(connectorId: string, streams: readonly Record<string, unknown>[]): void { + getDb() + .prepare( + `INSERT INTO connectors(connector_id, manifest, created_at) + VALUES (?, ?, ?)` + ) + .run( + connectorId, + JSON.stringify({ + connector_id: connectorId, + display_name: connectorId, + protocol_version: "0.1", + streams, + version: "1", + }), + EMITTED_AT + ); +} + +function streamManifest(name: string, field: string): Record<string, unknown> { + return { + consent_time_field: field, + name, + primary_key: "id", + schema: { + properties: { + id: { type: "string" }, + [field]: { format: "date-time", type: "string" }, + }, + required: ["id"], + type: "object", + }, + }; +} + +test("semantic ingest resolves arbitrary literal consent_time_field names", () => + withTempDb(() => { + for (const [index, field] of FIELD_NAMES.entries()) { + const connectorId = `ingest-field-${index}`; + const stream = `events-${index}`; + storeManifest(connectorId, [streamManifest(stream, field)]); + + assert.equal(getManifestConsentTimeField(connectorId, stream), field); + assert.equal( + computeIngestSemanticTime(connectorId, stream, { [field]: AUTHORED_AT, id: String(index) }, EMITTED_AT), + AUTHORED_AT, + field + ); + } + })); + +test("dataset-summary reconciliation forwards arbitrary literal consent_time_field names", () => + withTempDb(async () => { + const insert = getDb().prepare( + `INSERT INTO dataset_summary_stream_projection( + connector_id, + stream, + record_count, + record_json_bytes, + consent_time_field, + dirty_record_time_bounds, + computed_at + ) VALUES (?, ?, 1, 1, ?, 1, ?)` + ); + for (const [index, field] of FIELD_NAMES.entries()) { + insert.run("summary-fields", `events-${index}`, field, EMITTED_AT); + } + + const observed: string[] = []; + const result = await reconcileDirtyDatasetSummaryRecordTimeBounds({ + getStreamRecordTimeBounds(_connectorId, _stream, field) { + observed.push(field); + return { earliest: AUTHORED_AT, latest: AUTHORED_AT }; + }, + }); + + assert.deepEqual(result, { deferred: 0, reconciled: FIELD_NAMES.length, residual: 0 }); + assert.deepEqual( + observed.sort((left, right) => left.localeCompare(right)), + [...FIELD_NAMES].sort((left, right) => left.localeCompare(right)) + ); + })); + +test("reference timeline reads arbitrary literal consent_time_field names through bound JSON paths", () => + withTempDb(async () => { + const connectorId = "timeline-fields"; + const streams = FIELD_NAMES.map((field, index) => streamManifest(`events-${index}`, field)); + storeManifest(connectorId, streams); + + const insert = getDb().prepare( + `INSERT INTO records( + connector_id, + connector_instance_id, + stream, + record_key, + record_json, + emitted_at, + semantic_time + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + for (const [index, field] of FIELD_NAMES.entries()) { + insert.run( + connectorId, + connectorId, + `events-${index}`, + `record-${index}`, + JSON.stringify({ [field]: AUTHORED_AT, id: String(index) }), + EMITTED_AT, + AUTHORED_AT + ); + } + + const entriesByField = await Promise.all( + FIELD_NAMES.map((field, index) => + collectRecordsTimelineEntries({ + connectorId, + since: AUTHORED_AT, + stream: `events-${index}`, + }).then((entries) => ({ entries, field })) + ) + ); + for (const { entries, field } of entriesByField) { + assert.equal(entries.length, 1, field); + assert.equal(entries[0]?.display_timestamp, AUTHORED_AT, field); + assert.deepEqual(entries[0]?.semantic_timestamp, { field, value: AUTHORED_AT }, field); + } + })); diff --git a/reference-implementation/test/source-field-name-records-parity.test.ts b/reference-implementation/test/source-field-name-records-parity.test.ts new file mode 100644 index 000000000..c5578826a --- /dev/null +++ b/reference-implementation/test/source-field-name-records-parity.test.ts @@ -0,0 +1,337 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { registerConnector } from "../server/auth.ts"; +import { closeDb, initDb } from "../server/db.ts"; +import { + __buildPostgresFilterClauseForTest, + __buildPostgresGrantVisibilityForTest, +} from "../server/postgres-records.ts"; +import { closePostgresStorage, initPostgresStorage, postgresQuery } from "../server/postgres-storage.ts"; +import { + aggregateRecords as aggregateRecordsUntyped, + getRecordFieldWindow as getRecordFieldWindowUntyped, + getRecord as getRecordUntyped, + ingestRecord, + queryRecords as queryRecordsUntyped, +} from "../server/records.ts"; + +type ManifestLike = Record<string, unknown>; + +interface RecordItem { + data?: Record<string, unknown>; + expanded?: Record<string, { data: RecordItem[]; has_more: boolean }>; + id: string; +} + +interface RecordList { + data: RecordItem[]; +} + +interface AggregateResult { + filtered_record_count: number; + value: number; +} + +interface FieldWindowResult { + window: { text: string }; +} + +const queryRecords = queryRecordsUntyped as ( + storageTarget: string, + stream: string, + grant: unknown, + params: Record<string, unknown>, + manifest: ManifestLike +) => Promise<RecordList>; + +const getRecord = getRecordUntyped as ( + storageTarget: string, + stream: string, + key: string, + grant: unknown, + manifest: ManifestLike, + params?: Record<string, unknown> +) => Promise<RecordItem>; + +const aggregateRecords = aggregateRecordsUntyped as ( + storageTarget: string, + stream: string, + grant: unknown, + params: Record<string, unknown>, + manifest: ManifestLike +) => Promise<AggregateResult>; + +const getRecordFieldWindow = getRecordFieldWindowUntyped as ( + storageTarget: string, + stream: string, + key: string, + fieldPath: string, + grant: unknown, + manifest: ManifestLike, + params: Record<string, unknown> +) => Promise<FieldWindowResult>; + +const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; +const POSTGRES_EVENT_TIME_EXPR = /record_json->>'event-time'/; +const POSTGRES_FILTER_DOT_EXPR = /record_json->>'filter\.key'/; +const POSTGRES_QUOTED_EXPR = /record_json->>'say"when'/; +const POSTGRES_UNICODE_JSON_EXPR = /record_json->'時刻'/; +const POSTGRES_UNICODE_TEXT_EXPR = /record_json->>'時刻'/; + +test("Postgres SQL builders use literal JSON keys for filter and temporal fields", () => { + const manifestStream = { + name: "parents", + query: { range_filters: { "event-time": ["gte"] } }, + schema: { + properties: { + "event-time": { format: "date-time", type: "string" }, + "filter.key": { type: "string" }, + 'say"when': { type: "string" }, + }, + }, + }; + const filter = __buildPostgresFilterClauseForTest( + { "event-time": { gte: "2026-01-02T00:00:00.000Z" }, "filter.key": "include", 'say"when': "literal" }, + { fields: ["event-time", "filter.key", 'say"when'], name: "parents" } as never, + manifestStream as never + ); + assert.match(filter.clause, POSTGRES_EVENT_TIME_EXPR); + assert.match(filter.clause, POSTGRES_FILTER_DOT_EXPR); + assert.match(filter.clause, POSTGRES_QUOTED_EXPR); + + const temporal = __buildPostgresGrantVisibilityForTest({ + name: "children", + time_constraint: { field: "時刻", since: "2026-01-02T00:00:00.000Z" }, + } as never); + assert.match(temporal.whereParts.join(" "), POSTGRES_UNICODE_JSON_EXPR); + assert.match(temporal.whereParts.join(" "), POSTGRES_UNICODE_TEXT_EXPR); +}); + +function fixture(suffix: string) { + const connectorId = `field_name_parity_${suffix}`; + const parentStream = "parents"; + const childStream = "children"; + const parentFields = ["id.with.dot", "event-time", 'say"when', "filter.key", "Unicode 名"]; + const childFields = ["child-id", "child.time", "時刻", "parent.id", 'child "quote"']; + const manifest: ManifestLike = { + capabilities: { human_interaction: [] }, + connector_id: connectorId, + display_name: "Field-name records parity", + manifest_uri: `https://sources.example/${connectorId}`, + protocol_version: "0.1.0", + streams: [ + { + consent_time_field: 'say"when', + cursor_field: "event-time", + name: parentStream, + primary_key: ["id.with.dot"], + query: { + aggregations: { count: true }, + expand: [{ default_limit: 10, max_limit: 10, name: "children" }], + }, + relationships: [{ cardinality: "has_many", foreign_key: "parent.id", name: "children", stream: childStream }], + schema: { + properties: { + "event-time": { format: "date-time", type: "string" }, + "filter.key": { type: "string" }, + "id.with.dot": { type: "string" }, + 'say"when': { format: "date-time", type: "string" }, + "Unicode 名": { type: "string" }, + }, + required: ["id.with.dot"], + type: "object", + }, + selection: { fields: true, resources: false }, + semantics: "mutable_state", + }, + { + consent_time_field: "時刻", + cursor_field: "child.time", + name: childStream, + primary_key: ["child-id"], + schema: { + properties: { + 'child "quote"': { type: "string" }, + "child-id": { type: "string" }, + "child.time": { format: "date-time", type: "string" }, + "parent.id": { type: "string" }, + 時刻: { format: "date-time", type: "string" }, + }, + required: ["child-id", "parent.id"], + type: "object", + }, + selection: { fields: true, resources: false }, + semantics: "mutable_state", + }, + ], + version: "1.0.0", + }; + const grant = { + streams: [ + { + fields: parentFields, + name: parentStream, + time_constraint: { field: 'say"when', since: "2026-01-02T00:00:00.000Z" }, + }, + { + fields: childFields, + name: childStream, + time_constraint: { + field: "時刻", + since: "2026-01-02T00:00:00.000Z", + until: "2026-01-03T00:00:00.000Z", + }, + }, + ], + }; + return { childStream, connectorId, grant, manifest, parentStream }; +} + +async function seedAndAssert(suffix: string): Promise<void> { + const { childStream, connectorId, grant, manifest, parentStream } = fixture(suffix); + await registerConnector(manifest); + + await ingestRecord(connectorId, { + data: { + "event-time": "2026-01-02T00:00:00.000Z", + "filter.key": "include", + "id.with.dot": "parent-1", + 'say"when': "2026-01-02T00:00:00.000Z", + "Unicode 名": "Unicode field window payload", + }, + key: "parent-1", + stream: parentStream, + }); + await ingestRecord(connectorId, { + data: { + "event-time": "2026-01-01T00:00:00.000Z", + "filter.key": "exclude", + "id.with.dot": "parent-2", + 'say"when': "2026-01-01T00:00:00.000Z", + "Unicode 名": "outside parent temporal grant", + }, + key: "parent-2", + stream: parentStream, + }); + await ingestRecord(connectorId, { + data: { + 'child "quote"': "visible expanded child", + "child-id": "child-1", + "child.time": "2026-01-02T12:00:00.000Z", + "parent.id": "parent-1", + 時刻: "2026-01-02T12:00:00.000Z", + }, + key: "child-1", + stream: childStream, + }); + await ingestRecord(connectorId, { + data: { + 'child "quote"': "outside expanded child temporal grant", + "child-id": "child-2", + "child.time": "2026-01-03T00:00:00.000Z", + "parent.id": "parent-1", + 時刻: "2026-01-03T00:00:00.000Z", + }, + key: "child-2", + stream: childStream, + }); + + const list = await queryRecords( + connectorId, + parentStream, + grant, + { expand: "children", filter: { "filter.key": "include" }, limit: 10, sort: "event-time" }, + manifest + ); + assert.deepEqual( + list.data.map((row) => row.id), + ["parent-1"] + ); + const [listParent] = list.data; + assert.ok(listParent); + assert.equal(listParent.data?.['say"when'], "2026-01-02T00:00:00.000Z"); + assert.equal(listParent.data?.["Unicode 名"], "Unicode field window payload"); + const listChildren = listParent.expanded?.children; + assert.ok(listChildren); + assert.deepEqual( + listChildren.data.map((row) => row.id), + ["child-1"] + ); + assert.equal(listChildren.data[0]?.data?.['child "quote"'], "visible expanded child"); + + const detail = await getRecord(connectorId, parentStream, "parent-1", grant, manifest, { expand: "children" }); + assert.equal(detail.data?.["id.with.dot"], "parent-1"); + const detailChildren = detail.expanded?.children; + assert.ok(detailChildren); + assert.deepEqual( + detailChildren.data.map((row) => row.id), + ["child-1"] + ); + + const changes = await queryRecords( + connectorId, + parentStream, + grant, + { changes_since: "beginning", filter: { "filter.key": "include" } }, + manifest + ); + assert.deepEqual( + changes.data.map((row) => row.id), + ["parent-1"] + ); + + const aggregate = await aggregateRecords( + connectorId, + parentStream, + grant, + { filter: { "filter.key": "include" }, metric: "count" }, + manifest + ); + assert.equal(aggregate.filtered_record_count, 1); + assert.equal(aggregate.value, 1); + + const fieldWindow = await getRecordFieldWindow(connectorId, parentStream, "parent-1", "Unicode 名", grant, manifest, { + limit_chars: 100, + }); + assert.equal(fieldWindow.window.text, "Unicode field window payload"); +} + +test("SQLite record paths accept literal top-level JSON property names", async () => { + initDb(":memory:"); + try { + await seedAndAssert(`sqlite_${Date.now()}`); + } finally { + closeDb(); + } +}); + +if (POSTGRES_URL) { + test("Postgres record paths accept literal top-level JSON property names", async () => { + const suffix = `postgres_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; + const { connectorId } = fixture(suffix); + initDb(":memory:"); + await initPostgresStorage({ backend: "postgres", databaseUrl: POSTGRES_URL }); + try { + await seedAndAssert(suffix); + } finally { + try { + await postgresQuery("DELETE FROM record_changes WHERE connector_id = $1", [connectorId]); + await postgresQuery("DELETE FROM records WHERE connector_id = $1", [connectorId]); + await postgresQuery("DELETE FROM version_counter WHERE connector_id = $1", [connectorId]); + await postgresQuery("DELETE FROM connector_instances WHERE connector_id = $1", [connectorId]); + await postgresQuery("DELETE FROM connectors WHERE connector_id = $1", [connectorId]); + } finally { + await closePostgresStorage(); + closeDb(); + } + } + }); +} else { + test("Postgres record-field-name parity (skipped: PDPP_TEST_POSTGRES_URL unset)", { skip: true }, () => { + // The test body above runs unchanged when a live test database is configured. + }); +} diff --git a/reference-implementation/test/source-kind-runtime-neutrality.test.ts b/reference-implementation/test/source-kind-runtime-neutrality.test.ts new file mode 100644 index 000000000..d7b59eff5 --- /dev/null +++ b/reference-implementation/test/source-kind-runtime-neutrality.test.ts @@ -0,0 +1,404 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { emitSpineEvent } from "../lib/spine.ts"; +import { configureNativeManifest, registerConnector } from "../server/auth.ts"; +import { + type ConnectorSchemaManifestStream, + getConnectorFreshnessEvidence as getSchemaBuilderFreshnessEvidence, +} from "../server/connector-schema-builder.ts"; +import { closeDb } from "../server/db.ts"; +import { startServer } from "../server/index.ts"; +import { closePostgresStorage } from "../server/postgres-storage.ts"; +import { ingestRecord } from "../server/records.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; + +type SourceKind = "connector" | "provider_native"; + +interface Backend { + databaseUrl?: string; + name: "postgres" | "sqlite"; +} + +interface JsonObject { + [key: string]: any; +} + +interface TestServer { + asPort: number; + asServer: import("node:http").Server; + rsPort: number; + rsServer: import("node:http").Server; +} + +const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; +const CLIENT_ID = "concert_recommendation_app"; +const OWNER_ID = "owner_local"; + +function runtimeManifest(connectorKey: string): JsonObject { + return { + capabilities: { human_interaction: [] }, + connector_id: connectorKey, + connector_key: connectorKey, + display_name: "Source kind runtime neutrality", + manifest_uri: `https://implementations.example/connectors/${connectorKey}`, + protocol_version: "0.1.0", + streams: [ + { + name: "items", + primary_key: ["id"], + schema: { + properties: { id: { type: "string" }, label: { type: "string" } }, + required: ["id"], + type: "object", + }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", + }, + ], + version: "1.0.0", + }; +} + +function localFulfillment(sourceId: string, kind: SourceKind, connectorKey: string): JsonObject { + const runtime = runtimeManifest(connectorKey); + return { + source_declaration: { + declaration_version: `runtime-neutrality-${kind}-v1`, + display: { name: `Runtime neutrality ${kind}` }, + extensions: {}, + protocol_version: "0.1.0", + publisher: { id: "https://publishers.example/pdpp-test" }, + source: { id: sourceId, kind }, + streams: runtime.streams, + }, + storage_binding: { connector_id: connectorKey }, + streams: runtime.streams, + }; +} + +async function fetchJson(url: string, options: RequestInit = {}): Promise<{ body: JsonObject; status: number }> { + const response = await fetch(url, options); + const text = await response.text(); + return { + body: text ? (JSON.parse(text) as JsonObject) : {}, + status: response.status, + }; +} + +async function closeServer(server: TestServer | null): Promise<void> { + if (!server) { + return; + } + server.asServer.closeAllConnections(); + server.rsServer.closeAllConnections(); + await Promise.allSettled([ + new Promise<void>((resolve) => server.asServer.close(() => resolve())), + new Promise<void>((resolve) => server.rsServer.close(() => resolve())), + ]); +} + +async function runConsentGrantRead(backend: Backend, kind: SourceKind): Promise<void> { + const suffix = `${backend.name}_${kind}_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`; + const connectorKey = `source_kind_${suffix}`; + const connectorInstanceId = `cin_${suffix}`; + const sourceId = `https://sources.example/${suffix}`; + const fulfillment = localFulfillment(sourceId, kind, connectorKey); + let server: TestServer | null = null; + try { + server = (await startServer({ + asPort: 0, + ...(backend.databaseUrl ? { databaseUrl: backend.databaseUrl, storageBackend: "postgres" as const } : {}), + dbPath: ":memory:", + nativeManifest: fulfillment, + quiet: true, + reconcilePolyfillManifests: false, + rsPort: 0, + startClientEventDeliveryWorker: false, + })) as TestServer; + + await registerConnector(runtimeManifest(connectorKey)); + const now = new Date().toISOString(); + await createRequestConnectorInstanceStore().upsert({ + connectorId: connectorKey, + connectorInstanceId, + createdAt: now, + displayName: `Runtime neutrality ${kind}`, + ownerSubjectId: OWNER_ID, + sourceBinding: { fixture: suffix }, + sourceBindingKey: suffix, + sourceKind: "manual", + status: "active", + updatedAt: now, + }); + await ingestRecord( + { connector_id: connectorKey, connector_instance_id: connectorInstanceId }, + { + data: { id: `item-${suffix}`, label: `${kind} through connector storage` }, + key: `item-${suffix}`, + stream: "items", + } + ); + const runAt = new Date().toISOString(); + await emitSpineEvent({ + actor_id: connectorKey, + actor_type: "runtime", + data: { + connector_instance_id: connectorInstanceId, + source: { id: connectorKey, kind: "connector" }, + }, + event_type: "run.completed", + object_id: `run_${suffix}`, + object_type: "run", + occurred_at: runAt, + run_id: `run_${suffix}`, + source_id: connectorKey, + source_kind: "connector", + status: "succeeded", + }); + const schemaBuilderEvidence = await getSchemaBuilderFreshnessEvidence({ + manifest: runtimeManifest(connectorKey) as { capabilities?: unknown; streams: ConnectorSchemaManifestStream[] }, + storageBinding: { connector_id: connectorKey }, + }); + assert.deepEqual( + schemaBuilderEvidence.lastRun, + { last_at: runAt, status: "succeeded" }, + "connector-schema-builder must use the storage connector run" + ); + + const asUrl = `http://localhost:${server.asPort}`; + const rsUrl = `http://localhost:${server.rsPort}`; + const oppositeKind: SourceKind = kind === "connector" ? "provider_native" : "connector"; + const mismatch = await fetchJson(`${asUrl}/oauth/par`, { + body: JSON.stringify({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/runtime_neutrality_test", + source: { id: sourceId, kind: oppositeKind }, + streams: [{ name: "items" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(mismatch.status, 400, "request kind must match the retained declaration"); + assert.equal(mismatch.body.error.code, "invalid_request"); + + const initiated = await fetchJson(`${asUrl}/oauth/par`, { + body: JSON.stringify({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/runtime_neutrality_test", + source: { id: sourceId, kind }, + streams: [{ fields: ["id", "label"], name: "items" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(initiated.status, 201, JSON.stringify(initiated.body)); + assert.ok(initiated.body.request_uri); + + const review = await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: initiated.body.request_uri, subject_id: OWNER_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + assert.equal( + typeof review.body.approval_review_revision, + "string", + "consent review must return approval_review_revision" + ); + const approved = await fetchJson(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: review.body.approval_review_revision, + request_uri: initiated.body.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(approved.status, 200, JSON.stringify(approved.body)); + assert.ok(approved.body.token); + assert.deepEqual(approved.body.grant.source, { id: sourceId, kind }); + assert.deepEqual(approved.body.grant.streams[0].instance_ids, [connectorInstanceId]); + assert.equal("storage_binding" in approved.body.grant, false); + assert.equal(JSON.stringify(approved.body.grant).includes(connectorKey), false); + + const revokedAt = new Date().toISOString(); + await createRequestConnectorInstanceStore().updateStatus(connectorInstanceId, { + revokedAt, + status: "revoked", + updatedAt: revokedAt, + }); + + const schema = await fetchJson(`${rsUrl}/v1/schema`, { + headers: { Authorization: `Bearer ${approved.body.token}` }, + }); + assert.equal(schema.status, 200, JSON.stringify(schema.body)); + assert.deepEqual(schema.body.connectors[0].source, { id: sourceId, kind }); + assert.deepEqual(schema.body.connectors[0].streams[0].granted_connections, [ + { + connection_id: connectorInstanceId, + display_name: `Runtime neutrality ${kind}`, + }, + ]); + assert.equal( + schema.body.connectors[0].streams[0].freshness.last_attempted_at, + runAt, + "schema freshness must use the storage connector run, not the source URI" + ); + + const connectors = await fetchJson(`${rsUrl}/v1/connectors`, { + headers: { Authorization: `Bearer ${approved.body.token}` }, + }); + assert.equal(connectors.status, 200, JSON.stringify(connectors.body)); + assert.equal( + connectors.body.data[0].streams[0].freshness.last_attempted_at, + runAt, + "connector discovery freshness must use the storage connector run" + ); + + const streamMetadata = await fetchJson(`${rsUrl}/v1/streams/items`, { + headers: { Authorization: `Bearer ${approved.body.token}` }, + }); + assert.equal(streamMetadata.status, 200, JSON.stringify(streamMetadata.body)); + assert.equal( + streamMetadata.body.freshness.last_attempted_at, + runAt, + "stream freshness must use the storage connector run" + ); + + const records = await fetchJson(`${rsUrl}/v1/streams/items/records`, { + headers: { Authorization: `Bearer ${approved.body.token}` }, + }); + assert.equal(records.status, 200, JSON.stringify(records.body)); + assert.equal(records.body.data.length, 1); + assert.deepEqual(records.body.data[0].data, { + id: `item-${suffix}`, + label: `${kind} through connector storage`, + }); + } finally { + await closeServer(server); + configureNativeManifest(null); + await closePostgresStorage(); + closeDb(); + } +} + +async function approveConfiguredDefault(backend: Backend, kind: SourceKind): Promise<unknown[]> { + const sourceId = `https://sources.example/configured-default-${backend.name}`; + const connectorKey = `configured_default_${backend.name}`; + let server: TestServer | null = null; + try { + server = (await startServer({ + asPort: 0, + ...(backend.databaseUrl ? { databaseUrl: backend.databaseUrl, storageBackend: "postgres" as const } : {}), + dbPath: ":memory:", + nativeManifest: localFulfillment(sourceId, kind, connectorKey), + quiet: true, + reconcilePolyfillManifests: false, + rsPort: 0, + startClientEventDeliveryWorker: false, + })) as TestServer; + + const asUrl = `http://localhost:${server.asPort}`; + const initiated = await fetchJson(`${asUrl}/oauth/par`, { + body: JSON.stringify({ + authorization_details: [ + { + access_mode: "continuous", + purpose_code: "https://pdpp.dev/purpose/configured_default_neutrality_test", + source: { id: sourceId, kind }, + streams: [{ fields: ["id", "label"], name: "items" }], + type: "https://pdpp.dev/data-access", + }, + ], + client_id: CLIENT_ID, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(initiated.status, 201, JSON.stringify(initiated.body)); + + const review = await fetchJson(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: initiated.body.request_uri, subject_id: OWNER_ID }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + const reviewedInstanceIds = review.body.approval_review?.resolved_streams?.[0]?.instance_ids; + assert.deepEqual( + reviewedInstanceIds?.length, + 1, + "review resolves the configured default without a stored instance" + ); + + const approved = await fetchJson(`${asUrl}/consent/approve`, { + body: JSON.stringify({ + approval_review_revision: review.body.approval_review_revision, + request_uri: initiated.body.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(approved.status, 200, JSON.stringify(approved.body)); + assert.deepEqual( + approved.body.grant.streams[0].instance_ids, + reviewedInstanceIds, + "final issuance preserves the configured default selected at review" + ); + return reviewedInstanceIds as unknown[]; + } finally { + await closeServer(server); + configureNativeManifest(null); + await closePostgresStorage(); + closeDb(); + } +} + +test("source.kind is provenance while local connector storage fulfills reads on SQLite", async (t) => { + for (const kind of ["connector", "provider_native"] as const) { + // biome-ignore lint/performance/noAwaitInLoops: The runtime backend is process-global, so these journeys must be serialized. + await t.test(kind, () => runConsentGrantRead({ name: "sqlite" }, kind)); + } +}); + +test("configured fulfillment default is identical for connector and provider_native sources on SQLite", async () => { + const connector = await approveConfiguredDefault({ name: "sqlite" }, "connector"); + const providerNative = await approveConfiguredDefault({ name: "sqlite" }, "provider_native"); + assert.deepEqual(providerNative, connector); +}); + +test("source.kind is provenance while local connector storage fulfills reads on PostgreSQL", { + skip: POSTGRES_URL ? false : "PDPP_TEST_POSTGRES_URL is required", +}, async (t) => { + assert.ok(POSTGRES_URL); + for (const kind of ["connector", "provider_native"] as const) { + // biome-ignore lint/performance/noAwaitInLoops: The runtime backend is process-global, so these journeys must be serialized. + await t.test(kind, () => runConsentGrantRead({ databaseUrl: POSTGRES_URL, name: "postgres" }, kind)); + } +}); + +test("configured fulfillment default is identical for connector and provider_native sources on PostgreSQL", { + skip: POSTGRES_URL ? false : "PDPP_TEST_POSTGRES_URL is required", +}, async () => { + assert.ok(POSTGRES_URL); + const connector = await approveConfiguredDefault({ databaseUrl: POSTGRES_URL, name: "postgres" }, "connector"); + const providerNative = await approveConfiguredDefault( + { databaseUrl: POSTGRES_URL, name: "postgres" }, + "provider_native" + ); + assert.deepEqual(providerNative, connector); +}); diff --git a/reference-implementation/test/storage-fan-in-read-contract.test.ts b/reference-implementation/test/storage-fan-in-read-contract.test.ts index 336d8be97..1b03bcaf4 100644 --- a/reference-implementation/test/storage-fan-in-read-contract.test.ts +++ b/reference-implementation/test/storage-fan-in-read-contract.test.ts @@ -7,13 +7,12 @@ * Closes the deferred runtime tranche under * `openspec/changes/expose-connection-identity-on-public-read/tasks.md`: * - * - records list, aggregate, and streams list fan in across the granted - * connections when `connection_id` is omitted; + * - records list, aggregate, and streams list fan in across the grant's + * closed `instance_ids` when request-time `connection_id` is omitted; * - exactly-one matching connection auto-selects without raising; * - record detail emits `ambiguous_connection` with `available_connections` * when the identifier resolves to more than one connection; - * - grant scope `streams[].connection_id` narrows reads to one connection - * and preserves cross-connection (fan-in) semantics when absent; + * - per-stream `instance_ids` are the grant's closed connection authority; * - owner `setDisplayName` mutates `display_name` and surfaces it on the * subsequent records-list response; * - deprecated `connector_instance_id` request alias keeps working; @@ -44,6 +43,7 @@ import { resolveReadRequestBindings, validateConnectionAlias, } from "../server/records.ts"; +import { resolveClientStreamListBindingsOrEmpty } from "../server/routes/rs-read.ts"; import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; interface RecordListWarning { @@ -184,21 +184,24 @@ interface FanInBindingsResult { // `server/connection-identity.ts` is plain JS: the destructured `= null` // defaults give TS no other signal, so `requestConnectionId`/ -// `grantStreamConnectionId`/`connectorInstanceIdHint` all infer as exactly +// `authorizedInstanceIds`/`connectorInstanceIdHint` all infer as exactly // `null` (never `string`), rejecting every real call these tests make. // Re-typed here via the same documented pattern used elsewhere in this // cohort: import the real export and cast it to a signature matching how // it is actually called. type ResolveFanInBindingsFn = (args: { + authorizedInstanceIds?: string[]; connectorId: string | null | undefined; connectorInstanceIdHint?: string | null; - grantStreamConnectionId?: string | null; ownerSubjectId: string | null | undefined; requestConnectionId?: string | null; }) => Promise<FanInBindingsResult>; const resolveFanInBindings: ResolveFanInBindingsFn = async (args) => { - const result = await resolveFanInBindingsUntyped(args); + const result = await resolveFanInBindingsUntyped({ + ...args, + authorizedInstanceIds: args.authorizedInstanceIds ?? [INSTANCE_A, INSTANCE_B], + }); return { bindings: result.bindings.map((binding) => ({ connectorId: binding.connectorId, @@ -220,6 +223,7 @@ const baseManifest = { capabilities: { human_interaction: [] }, connector_id: CONNECTOR_ID, display_name: "Fan-in Test Connector", + manifest_uri: `https://sources.example/${CONNECTOR_ID}`, protocol_version: "0.1.0", streams: [ { @@ -244,13 +248,15 @@ const baseManifest = { required: ["id", "subject", "received_at"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", }; const grant = { - streams: [{ fields: ["id", "subject", "received_at"], name: STREAM }], + streams: [{ fields: ["id", "subject", "received_at"], instance_ids: [INSTANCE_A, INSTANCE_B], name: STREAM }], }; function target(instanceId: string) { @@ -320,6 +326,7 @@ async function withSingleConnectionDb(testFn: () => Promise<void>): Promise<void test("resolveFanInBindings returns both active bindings when no narrowing is requested", async () => { await withDualConnectionDb(async () => { const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A, INSTANCE_B], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); @@ -332,6 +339,7 @@ test("resolveFanInBindings returns both active bindings when no narrowing is req test("resolveFanInBindings narrows to a single binding when request supplies connection_id", async () => { await withDualConnectionDb(async () => { const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A, INSTANCE_B], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, requestConnectionId: INSTANCE_B, @@ -342,14 +350,15 @@ test("resolveFanInBindings narrows to a single binding when request supplies con }); }); -test("resolveFanInBindings rejects connection_id outside the grant with connection_not_found", async () => { +test("resolveFanInBindings rejects active connection_id outside instance_ids with connection_not_found", async () => { await withDualConnectionDb(async () => { await assert.rejects( () => resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, - requestConnectionId: "cin_does_not_exist", + requestConnectionId: INSTANCE_B, }), (err: unknown) => err !== null && @@ -362,11 +371,11 @@ test("resolveFanInBindings rejects connection_id outside the grant with connecti }); }); -test("resolveFanInBindings honors grant-scope connection_id constraint", async () => { +test("resolveFanInBindings starts from grant-scope instance_ids", async () => { await withDualConnectionDb(async () => { const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A], connectorId: CONNECTOR_ID, - grantStreamConnectionId: INSTANCE_A, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); assert.equal(bindings.length, 1); @@ -426,6 +435,8 @@ const firstPartyManifest = { required: ["id", "subject", "received_at"], type: "object", }, + selection: { fields: true, resources: true }, + semantics: "mutable_state", }, ], version: "1.0.0", @@ -471,7 +482,7 @@ test("resolveReadRequestBindings canonicalizes a URL-shaped storage binding to t // Storage binding still carries the legacy URL form (as a stale grant or // owner scope would). Admission must resolve the canonical instance. const { bindings } = await resolveReadRequestBindings({ - grant: { streams: [{ name: STREAM }] }, + grant: { streams: [{ instance_ids: [FIRST_PARTY_INSTANCE], name: STREAM }] }, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, requestParams: {}, storageBinding: { connector_id: FIRST_PARTY_URL_CONNECTOR_ID }, @@ -487,14 +498,14 @@ test("resolveReadRequestBindings canonicalizes a URL-shaped storage binding to t test("resolveReadRequestBindings resolves identically for the URL alias and the bare canonical key", async () => { await withFirstPartyUrlConnectorDb(async () => { const viaUrl = await resolveReadRequestBindings({ - grant: { streams: [{ name: STREAM }] }, + grant: { streams: [{ instance_ids: [FIRST_PARTY_INSTANCE], name: STREAM }] }, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, requestParams: {}, storageBinding: { connector_id: FIRST_PARTY_URL_CONNECTOR_ID }, streamName: STREAM, }); const viaCanonical = await resolveReadRequestBindings({ - grant: { streams: [{ name: STREAM }] }, + grant: { streams: [{ instance_ids: [FIRST_PARTY_INSTANCE], name: STREAM }] }, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, requestParams: {}, storageBinding: { connector_id: FIRST_PARTY_CANONICAL_KEY }, @@ -539,6 +550,7 @@ test("queryRecordsAcrossBindings preserves fan-in order and cursor collapse unde await ingestRecord(target(INSTANCE_C), recordPayload("rec-c-1", "C first", "2026-05-18T12:04:00.000Z")); const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A, INSTANCE_B, INSTANCE_C], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); @@ -569,12 +581,12 @@ test("queryRecordsAcrossBindings preserves fan-in order and cursor collapse unde }); }); -test("queryRecordsAcrossBindings narrows to one binding when bindings list is filtered", async () => { +test("record list starts from grant instance_ids and never includes active sibling B", async () => { await withDualConnectionDb(async () => { const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, - requestConnectionId: INSTANCE_A, }); const response = await queryRecordsAcrossBindings(bindings, STREAM, grant, {}, baseManifest); assert.equal(response.data.length, 2); @@ -588,6 +600,7 @@ test("queryRecordsAcrossBindings narrows to one binding when bindings list is fi test("queryRecordsAcrossBindings auto-selects exactly-one binding without raising", async () => { await withSingleConnectionDb(async () => { const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); @@ -903,6 +916,7 @@ test("queryRecordsAcrossBindings rejects changes_since under multi-binding fan-i test("queryRecordsAcrossBindings honors changes_since on the single-binding fast path", async () => { await withSingleConnectionDb(async () => { const { bindings } = await resolveFanInBindings({ + authorizedInstanceIds: [INSTANCE_A], connectorId: CONNECTOR_ID, ownerSubjectId: OWNER_AUTH_DEFAULT_SUBJECT_ID, }); @@ -1009,8 +1023,8 @@ test("aggregateRecordsAcrossBindings threads resolver warnings into multi-bindin }); }); -test("listStreamsAcrossBindings honors per-stream grant connection_id when resolver is supplied", async () => { - // Two-stream grant where each stream pins a different connection_id. +test("listStreamsAcrossBindings honors per-stream grant instance_ids when resolver is supplied", async () => { + // Two-stream grant where each stream authorizes a different instance. // Without per-stream resolution, the route would resolve bindings for // grant.streams[0] only and count stream B against binding A's storage. await withDualConnectionDb(async () => { @@ -1051,8 +1065,8 @@ test("listStreamsAcrossBindings honors per-stream grant connection_id when resol // Pinned grant: messages → connection A, tasks → connection B. const pinnedGrant = { streams: [ - { connection_id: INSTANCE_A, fields: ["id", "subject", "received_at"], name: STREAM }, - { connection_id: INSTANCE_B, fields: ["id", "received_at"], name: tasksStream }, + { fields: ["id", "subject", "received_at"], instance_ids: [INSTANCE_A], name: STREAM }, + { fields: ["id", "received_at"], instance_ids: [INSTANCE_B], name: tasksStream }, ], }; @@ -1062,7 +1076,7 @@ test("listStreamsAcrossBindings honors per-stream grant connection_id when resol // tasks. The per-stream resolver path must show both summaries with // honest counts. const ownerSubjectId = OWNER_AUTH_DEFAULT_SUBJECT_ID; - const resolverFor = async (streamGrant: { name: string; connection_id?: string }) => { + const resolverFor = async (streamGrant: { name: string; instance_ids?: string[] }) => { const { bindings } = await resolveReadRequestBindings({ grant: pinnedGrant, ownerSubjectId, @@ -1090,5 +1104,37 @@ test("listStreamsAcrossBindings honors per-stream grant connection_id when resol // because A is not authorized to read tasks. const tasksA = summaries.find((s) => s.name === tasksStream && s.connection_id === INSTANCE_A); assert.equal(tasksA, undefined, "tasks must not surface under A when grant pins tasks → B"); + + const reversePinnedGrant = { + streams: [ + { fields: ["id", "received_at"], instance_ids: [INSTANCE_B], name: tasksStream }, + { fields: ["id", "subject", "received_at"], instance_ids: [INSTANCE_A], name: STREAM }, + ], + }; + const requestParams = { connection_id: INSTANCE_A }; + const { bindings: grantWideBindings } = await resolveReadRequestBindings({ + grant: reversePinnedGrant, + ownerSubjectId, + requestParams, + storageBinding: { connector_id: CONNECTOR_ID }, + streamName: null, + }); + const narrowed = await listStreamsAcrossBindings(grantWideBindings, reversePinnedGrant, taskManifest, { + resolveBindingsForStream: (streamGrant) => + resolveClientStreamListBindingsOrEmpty(() => + resolveReadRequestBindings({ + grant: reversePinnedGrant, + ownerSubjectId, + requestParams, + storageBinding: { connector_id: CONNECTOR_ID }, + streamName: streamGrant.name, + }) + ), + }); + assert.deepEqual( + narrowed.map((summary) => `${summary.name}@${summary.connection_id}`), + [`${STREAM}@${INSTANCE_A}`], + "a grant-wide selector must not fail because the first stream authorizes another instance" + ); }); }); diff --git a/reference-implementation/test/token-refresh-postgres-path.test.ts b/reference-implementation/test/token-refresh-postgres-path.test.ts index a7b40658a..7459b41b9 100644 --- a/reference-implementation/test/token-refresh-postgres-path.test.ts +++ b/reference-implementation/test/token-refresh-postgres-path.test.ts @@ -24,8 +24,8 @@ * the consume UPDATE), during POST /oauth/token grant_type=authorization_code * - issueOAuthRefreshToken (oauth_refresh_tokens INSERT), minted alongside the * access token when the client supports refresh_token - * - exchangeOAuthRefreshToken (oauth_refresh_tokens SELECT-by-hash + the - * last_used_at UPDATE), during POST /oauth/token grant_type=refresh_token + * - exchangeOAuthRefreshToken (oauth_refresh_tokens family rotation), + * during POST /oauth/token grant_type=refresh_token * - introspect (the tokens SELECT join), via GET /oauth/introspect and the * internal exchange validation * - issueOwnerTokenRecord (tokens INSERT-owner), via the owner device flow @@ -51,16 +51,33 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { revokeGrant } from "../server/auth.ts"; +import { validateResponse } from "@pdpp/reference-contract"; + +import { + issueOAuthAuthorizationCodeForDeviceCode, + issueOAuthAuthorizationCodeForPackageDeviceCode, + issueToken, + revokeGrant, + stageOAuthAuthorizationCodeRequest, +} from "../server/auth.ts"; import { canonicalConnectorKeyFromManifest } from "../server/connector-key.ts"; import { closeDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; -import { closePostgresStorage } from "../server/postgres-storage.ts"; +import { basicIntrospectionAuthorization } from "../server/introspection-http.ts"; +import { bootstrapPostgresSchema, closePostgresStorage, postgresQuery } from "../server/postgres-storage.ts"; +import { createPostgresConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; +import { TEST_RS_INTROSPECTION_CREDENTIALS } from "./helpers/introspection-test-credentials.ts"; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; +const NOT_RECOVERABLE_OR_INVALID = /not recoverable|invalid/; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); +const SPOTIFY_INSTANCE_ID = "cin_pr89_token_refresh_spotify"; +const V01_LEGACY_BYTES = readFileSync( + join(REFERENCE_IMPL_DIR, "test/seam-spike/fixtures/pr89/legacy-grant-v01.bytes"), + "utf8" +).trim(); interface CloseableHttpServer { close: (callback: () => void) => unknown; @@ -103,6 +120,15 @@ function pkceChallenge(verifier: string): string { return createHash("sha256").update(verifier).digest("base64url"); } +function refreshTokenHash(refreshToken: string): string { + return createHash("sha256").update(refreshToken).digest("base64url"); +} + +const INTROSPECTION_HEADERS = { + Authorization: basicIntrospectionAuthorization(TEST_RS_INTROSPECTION_CREDENTIALS), + "Content-Type": "application/x-www-form-urlencoded", +}; + interface ConnectorManifest { connector_id: string; [extension: string]: unknown; @@ -110,15 +136,31 @@ interface ConnectorManifest { async function registerConnector(asUrl: string, name: string): Promise<ConnectorManifest> { const raw: ConnectorManifest = JSON.parse(readFileSync(join(REFERENCE_IMPL_DIR, `manifests/${name}.json`), "utf8")); - const canonical = canonicalConnectorKeyFromManifest(raw); - const manifest = !canonical || canonical === raw.connector_id ? raw : { ...raw, connector_id: canonical }; const { status } = await fetchJson(`${asUrl}/connectors`, { - body: JSON.stringify(manifest), + body: JSON.stringify(raw), headers: { "Content-Type": "application/json" }, method: "POST", }); assert.equal(status, 201); - return manifest; + return raw; +} + +async function seedActiveConnectorInstance(manifest: ConnectorManifest): Promise<void> { + const connectorId = canonicalConnectorKeyFromManifest(manifest); + assert.ok(connectorId, "registered manifest has a canonical storage connector key"); + const now = new Date().toISOString(); + await createPostgresConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId: SPOTIFY_INSTANCE_ID, + createdAt: now, + displayName: "PR89 token refresh fixture", + ownerSubjectId: "owner_local", + sourceBinding: { fixture: SPOTIFY_INSTANCE_ID }, + sourceBindingKey: SPOTIFY_INSTANCE_ID, + sourceKind: "account", + status: "active", + updatedAt: now, + }); } interface RegisteredClient { @@ -149,28 +191,36 @@ async function registerAuthCodeClient(asUrl: string, { refreshToken = true } = { interface OauthCodeFlowResult { accessToken: string; code: string; + expiresIn: number | undefined; grantId: string; refreshToken: string | null; verifier: string; } +interface PreparedOauthCodeFlow { + code: string; + verifier: string; +} + // Single-source authorization-code flow. Drives the oauth-code issue + // consume seams and (when the client supports refresh) the refresh-token // INSERT. Returns the access token, the refresh token, the grant id, and the // code so callers can assert single-use replay. -async function completeOauthCodeFlow({ +async function prepareOauthCodeFlow({ + accessMode = "continuous", asUrl, client, manifest, }: { + accessMode?: "continuous" | "single_use"; asUrl: string; client: RegisteredClient; manifest: ConnectorManifest; -}): Promise<OauthCodeFlowResult> { +}): Promise<PreparedOauthCodeFlow> { const verifier = randomBytes(32).toString("base64url"); const authorizationDetails = [ { - access_mode: "continuous", + access_mode: accessMode, purpose_code: "https://pdpp.dev/purpose/personal_ai_assistant", purpose_description: "token-refresh postgres-path proof", source: { id: manifest.connector_id, kind: "connector" }, @@ -195,14 +245,22 @@ async function completeOauthCodeFlow({ const requestUri = consentUrl.searchParams.get("request_uri"); assert.ok(requestUri, "authorize redirect carries a request_uri"); + const review = await fetchJson<{ approval_review_revision?: unknown }>(`${asUrl}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: "owner_local" }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(review.status, 200, JSON.stringify(review.body)); + assert.equal(typeof review.body.approval_review_revision, "string", "consent review returns a revision"); + const reviewRevision = review.body.approval_review_revision as string; // POST /consent/approve drives issueOAuthAuthorizationCodeForDeviceCode: // the oauth_authorization_codes SELECT-by-device + the issue UPDATE. const approveResp = await fetch(`${asUrl}/consent/approve`, { body: new URLSearchParams({ + approval_review_revision: reviewRevision, request_uri: requestUri, - subject_id: "owner_local", }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { Accept: "text/html", "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", redirect: "manual", }); @@ -215,6 +273,22 @@ async function completeOauthCodeFlow({ const code = callback.searchParams.get("code"); assert.ok(code, "approve callback carries an authorization code"); + return { code, verifier }; +} + +async function completeOauthCodeFlow({ + accessMode = "continuous", + asUrl, + client, + manifest, +}: { + accessMode?: "continuous" | "single_use"; + asUrl: string; + client: RegisteredClient; + manifest: ConnectorManifest; +}): Promise<OauthCodeFlowResult> { + const { code, verifier } = await prepareOauthCodeFlow({ accessMode, asUrl, client, manifest }); + // POST /oauth/token grant_type=authorization_code drives // exchangeOAuthAuthorizationCode (oauth_authorization_codes SELECT-by-code + // the consume UPDATE), introspect (the tokens SELECT join), and, when the @@ -222,6 +296,7 @@ async function completeOauthCodeFlow({ // INSERT). interface TokenResponseBody { access_token: string; + expires_in?: number; grant_id: string; refresh_token?: string; token_type: string; @@ -243,6 +318,7 @@ async function completeOauthCodeFlow({ return { accessToken: body.access_token, code, + expiresIn: body.expires_in, grantId: body.grant_id, refreshToken: body.refresh_token || null, verifier, @@ -256,6 +332,7 @@ if (POSTGRES_URL) { // breaks a Postgres-only adapter and this suite goes red. let server: TestServer | undefined; let asUrl = ""; + let rsUrl = ""; let client: RegisteredClient | undefined; let spotify: ConnectorManifest | undefined; @@ -266,13 +343,16 @@ if (POSTGRES_URL) { server = await startServer({ asPort: 0, dbPath: ":memory:", + introspectionCallerCredentials: TEST_RS_INTROSPECTION_CREDENTIALS, ownerAuthPassword: "", quiet: true, reconcilePolyfillManifests: false, rsPort: 0, }); asUrl = `http://localhost:${server.asPort}`; + rsUrl = `http://localhost:${server.rsPort}`; spotify = await registerConnector(asUrl, "spotify"); + await seedActiveConnectorInstance(spotify); client = await registerAuthCodeClient(asUrl); }); @@ -280,6 +360,7 @@ if (POSTGRES_URL) { if (server) { await closeServer(server); } + await postgresQuery("DELETE FROM connector_instances WHERE connector_instance_id = $1", [SPOTIFY_INSTANCE_ID]); await closePostgresStorage(); closeDb(); }); @@ -339,7 +420,7 @@ if (POSTGRES_URL) { // Introspect the owner token: the tokens SELECT join (PG introspect adapter). const introspectResp = await fetchJson<IntrospectBody>(`${asUrl}/introspect`, { body: new URLSearchParams({ token: tokenBody.access_token }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: INTROSPECTION_HEADERS, method: "POST", }); assert.equal(introspectResp.status, 200); @@ -352,15 +433,439 @@ if (POSTGRES_URL) { // // Exercises the oauth_authorization_codes seams (issue + consume), the // oauth_refresh_tokens INSERT, the introspect tokens SELECT, and the - // refresh-token exchange (SELECT-by-hash + last_used_at UPDATE). + // refresh-token exchange and atomic family rotation. // --------------------------------------------------------------------- + test("authorization-code redemption has one PostgreSQL race winner", async () => { + assert.ok(client, "client must be registered in test.before"); + assert.ok(spotify, "spotify manifest must be registered in test.before"); + const registeredClient = client; + const prepared = await prepareOauthCodeFlow({ asUrl, client: registeredClient, manifest: spotify }); + const redeem = () => + fetchJson<{ access_token?: string; error?: string }>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: registeredClient.client_id, + code: prepared.code, + code_verifier: prepared.verifier, + grant_type: "authorization_code", + redirect_uri: "https://client.example/callback", + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + + const results = await Promise.all([redeem(), redeem()]); + assert.deepEqual( + results.map(({ status }) => status).sort(), + [200, 400], + "one redemption succeeds and one loses the atomic consume" + ); + assert.equal(results.find(({ status }) => status === 400)?.body.error, "invalid_grant"); + + const persisted = await postgresQuery<{ + consumed_at: string | null; + status: string; + token_count: number; + }>( + `SELECT c.consumed_at, c.status, COUNT(t.token_id)::int AS token_count + FROM oauth_authorization_codes c + LEFT JOIN tokens t ON t.token_id = c.token_id + WHERE c.code = $1 + GROUP BY c.consumed_at, c.status`, + [prepared.code] + ); + assert.equal(persisted.rows[0]?.status, "consumed"); + assert.ok(persisted.rows[0]?.consumed_at, "winner records the consumption timestamp"); + assert.equal(persisted.rows[0]?.token_count, 1, "the code remains bound to exactly one bearer row"); + + const sequentialReplay = await redeem(); + assert.equal(sequentialReplay.status, 400); + assert.equal(sequentialReplay.body.error, "invalid_grant"); + }); + + test("authorization-code failure rolls back PostgreSQL consumption with initial refresh issuance", async () => { + assert.ok(client, "client must be registered in test.before"); + assert.ok(spotify, "spotify manifest must be registered in test.before"); + const registeredClient = client; + const prepared = await prepareOauthCodeFlow({ asUrl, client: registeredClient, manifest: spotify }); + await postgresQuery(` + CREATE OR REPLACE FUNCTION fail_initial_refresh_issuance() + RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'injected initial refresh failure'; + END; + $$ LANGUAGE plpgsql + `); + await postgresQuery(` + CREATE TRIGGER fail_initial_refresh_issuance + BEFORE INSERT ON oauth_refresh_tokens + FOR EACH ROW EXECUTE FUNCTION fail_initial_refresh_issuance() + `); + const redeem = () => + fetchJson<{ error?: string; refresh_token?: string }>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: registeredClient.client_id, + code: prepared.code, + code_verifier: prepared.verifier, + grant_type: "authorization_code", + redirect_uri: "https://client.example/callback", + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + + const failed = await redeem(); + assert.notEqual(failed.status, 200); + const afterFailure = await postgresQuery<{ consumed_at: string | null; status: string }>( + "SELECT status, consumed_at FROM oauth_authorization_codes WHERE code = $1", + [prepared.code] + ); + assert.deepEqual(afterFailure.rows[0], { consumed_at: null, status: "issued" }); + + await postgresQuery("DROP TRIGGER fail_initial_refresh_issuance ON oauth_refresh_tokens"); + await postgresQuery("DROP FUNCTION fail_initial_refresh_issuance()"); + const retried = await redeem(); + assert.equal(retried.status, 200); + assert.equal(typeof retried.body.refresh_token, "string"); + }); + + test("authorization-code delivery converges and recovers on PostgreSQL", async () => { + assert.ok(client, "client must be registered in test.before"); + const challenge = pkceChallenge(randomBytes(32).toString("base64url")); + const redirectUri = "https://client.example/callback"; + const exercise = async ( + kind: "grant" | "package", + deviceCode: string, + binding: { grantId: string; token: string } | { packageId: string; token: string } + ) => { + await stageOAuthAuthorizationCodeRequest({ + clientId: client?.client_id, + codeChallenge: challenge, + codeChallengeMethod: "S256", + deviceCode, + redirectUri, + }); + const issue = () => + kind === "grant" + ? issueOAuthAuthorizationCodeForDeviceCode(deviceCode, binding as { grantId: string; token: string }) + : issueOAuthAuthorizationCodeForPackageDeviceCode( + deviceCode, + binding as { packageId: string; token: string } + ); + const issued = await Promise.all([issue(), issue()]); + assert.deepEqual(issued[1], issued[0]); + assert.equal(issued[0]?.redirect_uri, redirectUri); + assert.equal(typeof issued[0]?.code, "string"); + return issued[0]; + }; + + const grantDeviceCode = `device_delivery_grant_${randomBytes(6).toString("hex")}`; + const grantCode = await exercise("grant", grantDeviceCode, { + grantId: "grt_delivery", + token: "tok_delivery", + }); + await postgresQuery( + "UPDATE oauth_authorization_codes SET status = 'consumed', consumed_at = NOW() WHERE code = $1", + [grantCode?.code] + ); + await assert.rejects( + () => + issueOAuthAuthorizationCodeForDeviceCode(grantDeviceCode, { + grantId: "grt_delivery", + token: "tok_delivery", + }), + NOT_RECOVERABLE_OR_INVALID + ); + await exercise("package", `device_delivery_package_${randomBytes(6).toString("hex")}`, { + packageId: "gpkg_delivery", + token: "tok_package_delivery", + }); + }); + + test("single-use grant issuance has one PostgreSQL race winner", async () => { + assert.ok(client, "client must be registered in test.before"); + assert.ok(spotify, "spotify manifest must be registered in test.before"); + const registeredClient = client; + const grantId = `grt_pr89_single_use_${randomBytes(8).toString("hex")}`; + const connectorId = canonicalConnectorKeyFromManifest(spotify); + assert.ok(connectorId, "spotify manifest has a canonical storage connector key"); + const sourceId = spotify.connector_id; + await postgresQuery( + `INSERT INTO grants( + grant_id, subject_id, client_id, storage_binding_json, grant_json, + access_mode, status, consumed, issued_at, expires_at + ) VALUES($1, $2, $3, $4::jsonb, $5::jsonb, 'single_use', 'active', FALSE, $6, NULL)`, + [ + grantId, + "owner_local", + registeredClient.client_id, + JSON.stringify({ connector_id: connectorId }), + JSON.stringify({ + access_mode: "single_use", + client: { client_id: registeredClient.client_id }, + grant_id: grantId, + issued_at: "2026-08-11T12:00:00Z", + purpose_code: "https://pdpp.dev/purpose/personal_ai_assistant", + source: { id: sourceId, kind: "connector" }, + source_declaration: { version: "1.0.0" }, + streams: [{ fields: ["id", "name"], instance_ids: [SPOTIFY_INSTANCE_ID], name: "top_artists" }], + subject: { id: "owner_local" }, + version: "0.1.0", + }), + "2026-08-11T12:00:00Z", + ] + ); + + const issue = () => issueToken(grantId, "owner_local", registeredClient.client_id, null, { source: "pr89_seam" }); + const results = await Promise.allSettled([issue(), issue()]); + assert.equal(results.filter(({ status }) => status === "fulfilled").length, 1); + const loser = results.find(({ status }) => status === "rejected"); + assert.ok(loser?.status === "rejected"); + assert.equal((loser.reason as { code?: string }).code, "grant_consumed"); + + const persisted = await postgresQuery<{ consumed: boolean; token_count: number }>( + `SELECT g.consumed, COUNT(t.token_id)::int AS token_count + FROM grants g + LEFT JOIN tokens t ON t.grant_id = g.grant_id + WHERE g.grant_id = $1 + GROUP BY g.consumed`, + [grantId] + ); + assert.equal(persisted.rows[0]?.consumed, true); + assert.equal(persisted.rows[0]?.token_count, 1); + }); + + test("migrated pre-family PostgreSQL refresh rows fail closed without reconstruction", async () => { + assert.ok(client, "client must be registered in test.before"); + const legacyToken = `rt_${randomBytes(32).toString("base64url")}`; + const legacyHash = refreshTokenHash(legacyToken); + await postgresQuery("ALTER TABLE oauth_refresh_tokens ALTER COLUMN family_id DROP NOT NULL"); + await postgresQuery("ALTER TABLE oauth_refresh_tokens ALTER COLUMN generation DROP NOT NULL"); + try { + await postgresQuery( + `INSERT INTO oauth_refresh_tokens( + refresh_token_hash, family_id, generation, parent_generation, + client_id, grant_id, subject_id, status, created_at + ) VALUES($1, NULL, NULL, NULL, $2, $3, $4, 'active', $5)`, + [legacyHash, client.client_id, "grt_pre_family", "owner_local", "2026-08-11T12:00:00Z"] + ); + const response = await fetchJson<{ error?: string }>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: legacyToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(response.status, 400); + assert.equal(response.body.error, "invalid_grant"); + const row = await postgresQuery<{ + family_id: string | null; + generation: number | null; + parent_generation: number | null; + status: string; + }>( + `SELECT family_id, generation, parent_generation, status + FROM oauth_refresh_tokens + WHERE refresh_token_hash = $1`, + [legacyHash] + ); + assert.deepEqual(row.rows[0], { + family_id: null, + generation: null, + parent_generation: null, + status: "active", + }); + } finally { + await postgresQuery("DELETE FROM oauth_refresh_tokens WHERE refresh_token_hash = $1", [legacyHash]); + await postgresQuery("ALTER TABLE oauth_refresh_tokens ALTER COLUMN family_id SET NOT NULL"); + await postgresQuery("ALTER TABLE oauth_refresh_tokens ALTER COLUMN generation SET NOT NULL"); + } + }); + + test("pre-v0.1 PostgreSQL grant bytes fail before the RS route", async () => { + assert.ok(client, "client must be registered in test.before"); + const suffix = randomBytes(8).toString("hex"); + const grantId = `grt_legacy_${suffix}`; + const token = `tok_legacy_${suffix}`; + await postgresQuery( + `INSERT INTO grants( + grant_id, subject_id, client_id, storage_binding_json, grant_json, + access_mode, status, consumed, issued_at + ) VALUES($1, $2, $3, NULL, $4::jsonb, 'continuous', 'active', FALSE, $5)`, + [ + grantId, + "owner_local", + client.client_id, + V01_LEGACY_BYTES.replace("grt_legacy", grantId).replace("legacy_client", client.client_id), + "2026-08-11T12:00:00Z", + ] + ); + await postgresQuery( + `INSERT INTO tokens(token_id, grant_id, subject_id, client_id, token_kind, expires_at, revoked) + VALUES($1, $2, $3, $4, 'client', NULL, FALSE)`, + [token, grantId, "owner_local", client.client_id] + ); + + const introspection = await fetchJson<{ + active: boolean; + inactive_reason?: string; + }>(`${asUrl}/introspect`, { + body: new URLSearchParams({ token }).toString(), + headers: INTROSPECTION_HEADERS, + method: "POST", + }); + assert.equal(introspection.status, 200); + assert.equal(introspection.body.active, false); + assert.equal(introspection.body.inactive_reason, "authorization_state.unsupported_legacy_shape"); + + const route = await fetchJson<{ error?: { code?: string } }>(`${rsUrl}/v1/schema`, { + headers: { Authorization: `Bearer ${token}` }, + }); + assert.equal(route.status, 401); + assert.equal(route.body.error?.code, "authorization_state.unsupported_legacy_shape"); + }); + + test("PostgreSQL migration revokes unlinked legacy refresh families and bound bearers", async () => { + const suffix = randomBytes(8).toString("hex"); + const grantId = `grt_legacy_family_${suffix}`; + const familyId = `rtf_legacy_family_${suffix}`; + const tokenId = `tok_legacy_family_${suffix}`; + await postgresQuery( + `INSERT INTO tokens(token_id, grant_id, subject_id, client_id, token_kind) + VALUES($1, $2, 'owner_local', 'client_legacy', 'client')`, + [tokenId, grantId] + ); + await postgresQuery( + `INSERT INTO oauth_refresh_tokens( + refresh_token_hash, family_id, generation, client_id, grant_id, + subject_id, status, created_at + ) VALUES($1, $2, 0, 'client_legacy', $3, 'owner_local', 'active', NOW())`, + [`hash_legacy_family_${suffix}`, familyId, grantId] + ); + + await bootstrapPostgresSchema(); + + const refresh = await postgresQuery<{ revoked_at: string | null; status: string }>( + "SELECT status, revoked_at FROM oauth_refresh_tokens WHERE family_id = $1", + [familyId] + ); + const bearer = await postgresQuery<{ refresh_family_id: string | null; revoked: boolean }>( + "SELECT refresh_family_id, revoked FROM tokens WHERE token_id = $1", + [tokenId] + ); + assert.equal(refresh.rows[0]?.status, "revoked", "unlinked pre-migration family requires fresh authorization"); + assert.ok(refresh.rows[0]?.revoked_at); + assert.deepEqual(bearer.rows[0], { refresh_family_id: null, revoked: true }); + }); + + test("PostgreSQL supersede failure rolls back the newly inserted family bearer", async () => { + assert.ok(client, "client must be registered in test.before"); + assert.ok(spotify, "spotify manifest must be registered in test.before"); + const issued = await completeOauthCodeFlow({ asUrl, client, manifest: spotify }); + assert.ok(issued.refreshToken, "supersede-fault flow receives generation zero"); + const generationZero = issued.refreshToken; + + await postgresQuery(` + CREATE OR REPLACE FUNCTION pdpp_test_fail_refresh_supersede() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF OLD.status = 'active' AND NEW.status = 'superseded' THEN + RAISE EXCEPTION 'injected refresh supersede failure'; + END IF; + RETURN NEW; + END + $function$ + `); + await postgresQuery(` + CREATE TRIGGER fail_refresh_supersede + BEFORE UPDATE OF status ON oauth_refresh_tokens + FOR EACH ROW EXECUTE FUNCTION pdpp_test_fail_refresh_supersede() + `); + try { + const failure = await fetch(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: generationZero, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.notEqual(failure.status, 200); + } finally { + await postgresQuery("DROP TRIGGER IF EXISTS fail_refresh_supersede ON oauth_refresh_tokens"); + await postgresQuery("DROP FUNCTION IF EXISTS pdpp_test_fail_refresh_supersede() "); + } + + const family = await postgresQuery<{ generation: number; status: string }>( + `SELECT generation, status + FROM oauth_refresh_tokens + WHERE family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = $1 + ) + ORDER BY generation`, + [refreshTokenHash(generationZero)] + ); + assert.deepEqual(family.rows, [{ generation: 0, status: "active" }]); + const bearers = await postgresQuery<{ revoked: boolean }>( + `SELECT revoked + FROM tokens + WHERE refresh_family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = $1 + )`, + [refreshTokenHash(generationZero)] + ); + assert.deepEqual(bearers.rows, [{ revoked: false }], "failed supersede leaves no orphan bearer"); + + const retried = await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: generationZero, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(retried.status, 200, "generation zero remains usable after rollback"); + }); + + test("PostgreSQL token lifetime and refresh eligibility follow the persisted grant contract", async () => { + assert.ok(client, "client must be registered in test.before"); + assert.ok(spotify, "spotify manifest must be registered in test.before"); + const noRefreshClient = await registerAuthCodeClient(asUrl, { refreshToken: false }); + const indefinite = await completeOauthCodeFlow({ asUrl, client: noRefreshClient, manifest: spotify }); + assert.equal(indefinite.refreshToken, null, "client without refresh capability receives no refresh token"); + assert.equal(indefinite.expiresIn, undefined, "token response omits expires_in when storage has no expiry"); + const indefiniteIntrospection = await fetchJson<{ active: boolean; exp?: number }>(`${asUrl}/introspect`, { + body: new URLSearchParams({ token: indefinite.accessToken }).toString(), + headers: INTROSPECTION_HEADERS, + method: "POST", + }); + assert.equal(indefiniteIntrospection.body.active, true); + assert.equal( + Object.hasOwn(indefiniteIntrospection.body, "exp"), + false, + "RFC 7662 response omits exp when the token has no expiration" + ); + + const singleUse = await completeOauthCodeFlow({ accessMode: "single_use", asUrl, client, manifest: spotify }); + assert.equal(singleUse.refreshToken, null, "single_use grant receives no refresh token"); + assert.ok(singleUse.expiresIn && singleUse.expiresIn > 600, "single_use token keeps its persisted grant lifetime"); + }); + test("authorization-code exchange + refresh rotation through real auth.js postgres adapters", async () => { interface IntrospectBody { active: boolean; + exp?: number; } interface TokenExchangeBody { access_token?: string; error?: string; + expires_in?: number; + fresh_authorization_required?: boolean; refresh_token?: string; } @@ -368,16 +873,18 @@ if (POSTGRES_URL) { assert.ok(spotify, "spotify manifest must be registered in test.before"); const issued = await completeOauthCodeFlow({ asUrl, client, manifest: spotify }); assert.ok(issued.refreshToken, "refresh-capable client receives a refresh token"); + assert.ok(issued.expiresIn && issued.expiresIn <= 600, "family-linked access token has a short lifetime"); const issuedRefreshToken = issued.refreshToken; // Introspect the access token: the tokens SELECT join (PG introspect adapter). const introspectResp = await fetchJson<IntrospectBody>(`${asUrl}/introspect`, { body: new URLSearchParams({ token: issued.accessToken }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: INTROSPECTION_HEADERS, method: "POST", }); assert.equal(introspectResp.status, 200); assert.equal(introspectResp.body.active, true, "issued access token introspects as active"); + assert.equal(typeof introspectResp.body.exp, "number", "persisted access expiry is exposed through introspection"); // Replaying the consumed code must fail: the consume UPDATE flipped the // row to status=consumed and the SELECT-by-code adapter reads it back. @@ -395,9 +902,8 @@ if (POSTGRES_URL) { assert.equal(replay.status, 400, "replaying a consumed code is rejected"); assert.equal(replay.body.error, "invalid_grant"); - // grant_type=refresh_token drives exchangeOAuthRefreshToken: the - // oauth_refresh_tokens SELECT-by-hash + the last_used_at UPDATE, and mints - // a fresh access token via issueToken. + // grant_type=refresh_token atomically supersedes the presented generation, + // inserts its successor, and mints a fresh access token via issueToken. const refreshed = await fetchJson<TokenExchangeBody>(`${asUrl}/oauth/token`, { body: new URLSearchParams({ client_id: client.client_id, @@ -407,37 +913,391 @@ if (POSTGRES_URL) { headers: { "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", }); - assert.equal(refreshed.status, 200, "refresh exchange succeeds"); + assert.equal(refreshed.status, 200, `refresh exchange succeeds: ${JSON.stringify(refreshed.body)}`); assert.ok(refreshed.body.access_token, "refresh returns a new access token"); assert.notEqual(refreshed.body.access_token, issued.accessToken, "refresh mints a distinct access token"); - assert.equal( - refreshed.body.refresh_token, - issuedRefreshToken, - "refresh token is reusable (last_used_at UPDATE, not rotation)" + assert.ok(refreshed.body.refresh_token, "refresh returns a successor refresh token"); + assert.ok( + refreshed.body.expires_in && refreshed.body.expires_in <= 600, + "refresh-derived access token reports its actual short lifetime" ); + assert.notEqual(refreshed.body.refresh_token, issuedRefreshToken, "refresh rotates the presented token"); // The new access token introspects as active (tokens SELECT join again). assert.ok(refreshed.body.access_token, "refresh must have returned an access token to introspect"); const refreshedIntrospect = await fetchJson<IntrospectBody>(`${asUrl}/introspect`, { body: new URLSearchParams({ token: refreshed.body.access_token }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: INTROSPECTION_HEADERS, method: "POST", }); assert.equal(refreshedIntrospect.body.active, true, "refreshed access token is active"); - // A wrong-client refresh must be rejected (SELECT-by-hash reads the row, - // client_id mismatch fails). Proves the SELECT adapter returns the bound row. + // A wrong-client refresh must be rejected without consuming the successor. const wrongClient = await fetchJson(`${asUrl}/oauth/token`, { body: new URLSearchParams({ client_id: "not-the-issuing-client", grant_type: "refresh_token", - refresh_token: issued.refreshToken, + refresh_token: refreshed.body.refresh_token, }).toString(), headers: { "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", }); assert.equal(wrongClient.status, 400, "refresh with the wrong client is rejected"); assert.equal(wrongClient.body.error, "invalid_grant"); + + const reuse = await fetchJson<TokenExchangeBody>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: issuedRefreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(reuse.status, 400, "reusing a superseded generation is rejected"); + assert.equal(reuse.body.error, "invalid_grant"); + assert.equal(reuse.body.fresh_authorization_required, true); + assert.deepEqual(validateResponse("exchangeOwnerDeviceToken", { body: reuse.body, status: reuse.status }), { + ok: true, + skipped: false, + }); + + const family = await postgresQuery<{ + family_id: string; + generation: number; + parent_generation: number | null; + status: string; + }>( + `SELECT family_id, generation, parent_generation, status + FROM oauth_refresh_tokens + WHERE family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = $1 + ) + ORDER BY generation`, + [refreshTokenHash(issuedRefreshToken)] + ); + assert.deepEqual( + family.rows.map(({ generation, parent_generation: parentGeneration, status }) => ({ + generation, + parentGeneration, + status, + })), + [ + { generation: 0, parentGeneration: null, status: "revoked" }, + { generation: 1, parentGeneration: 0, status: "revoked" }, + ] + ); + + const familyAccessTokens = await postgresQuery<{ + expires_at: string | null; + refresh_family_id: string; + revoked: boolean; + token_id: string; + }>( + `SELECT token_id, refresh_family_id, expires_at, revoked + FROM tokens + WHERE refresh_family_id = $1 + ORDER BY created_at, token_id`, + [family.rows[0]?.family_id] + ); + assert.deepEqual( + familyAccessTokens.rows.map(({ revoked }) => revoked), + [true, true], + "replay revokes the initial and attacker-minted access tokens" + ); + for (const bearer of familyAccessTokens.rows) { + assert.ok(bearer.expires_at, "every family-derived access token has an expiry"); + // biome-ignore lint/performance/noAwaitInLoops: Each persisted family bearer is an independent security assertion. + const introspection = await fetchJson<IntrospectBody>(`${asUrl}/introspect`, { + body: new URLSearchParams({ token: bearer.token_id }).toString(), + headers: INTROSPECTION_HEADERS, + method: "POST", + }); + assert.equal(introspection.body.active, false, "every family bearer introspects inactive after replay"); + } + + const successorAfterReuse = await fetchJson<TokenExchangeBody>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: refreshed.body.refresh_token, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(successorAfterReuse.status, 400, "family reuse revokes the active successor"); + assert.equal(successorAfterReuse.body.error, "invalid_grant"); + + const concurrent = await completeOauthCodeFlow({ asUrl, client, manifest: spotify }); + assert.ok(concurrent.refreshToken, "concurrency flow receives a refresh token"); + const concurrentClientId = client.client_id; + const concurrentRefreshToken = concurrent.refreshToken; + const exchangeConcurrently = () => + fetchJson<TokenExchangeBody>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: concurrentClientId, + grant_type: "refresh_token", + refresh_token: concurrentRefreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + const concurrentResults = await Promise.all([exchangeConcurrently(), exchangeConcurrently()]); + assert.deepEqual( + concurrentResults.map(({ status }) => status).sort(), + [200, 400], + "exactly one concurrent refresh rotates" + ); + const concurrentFailure = concurrentResults.find(({ status }) => status === 400); + assert.equal(concurrentFailure?.body.error, "invalid_grant"); + assert.equal(concurrentFailure?.body.fresh_authorization_required, true); + + const concurrentFamily = await postgresQuery<{ generation: number; status: string }>( + `SELECT generation, status + FROM oauth_refresh_tokens + WHERE family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = $1 + ) + ORDER BY generation`, + [refreshTokenHash(concurrentRefreshToken)] + ); + assert.deepEqual( + concurrentFamily.rows.map(({ generation, status }) => ({ generation, status })), + [ + { generation: 0, status: "revoked" }, + { generation: 1, status: "revoked" }, + ], + "reuse detection leaves no active successor" + ); + const concurrentBearers = await postgresQuery<{ active_count: number }>( + `SELECT COUNT(*) FILTER (WHERE revoked = FALSE)::int AS active_count + FROM tokens + WHERE refresh_family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = $1 + )`, + [refreshTokenHash(concurrentRefreshToken)] + ); + assert.equal(concurrentBearers.rows[0]?.active_count, 0, "same-generation replay leaves no active family bearer"); + + const crossGeneration = await completeOauthCodeFlow({ asUrl, client, manifest: spotify }); + assert.ok(crossGeneration.refreshToken, "cross-generation race receives generation zero"); + const generationZero = crossGeneration.refreshToken; + const generationOneResponse = await fetchJson<TokenExchangeBody>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: generationZero, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(generationOneResponse.status, 200); + assert.ok(generationOneResponse.body.refresh_token, "first rotation returns generation one"); + const generationOne = generationOneResponse.body.refresh_token; + const crossGenerationClientId = client.client_id; + const exchangeRefresh = (refreshToken: string) => + fetchJson<TokenExchangeBody>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: crossGenerationClientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + const [generationZeroReplay, generationOneRotation] = await Promise.all([ + exchangeRefresh(generationZero), + exchangeRefresh(generationOne), + ]); + assert.equal(generationZeroReplay.status, 400); + assert.equal(generationZeroReplay.body.error, "invalid_grant"); + assert.equal(generationZeroReplay.body.fresh_authorization_required, true); + assert.ok( + generationOneRotation.status === 200 || generationOneRotation.status === 400, + "generation one either rotates before replay wins or observes family revocation" + ); + + const crossGenerationFamily = await postgresQuery<{ generation: number; status: string }>( + `SELECT generation, status + FROM oauth_refresh_tokens + WHERE family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = $1 + ) + ORDER BY generation`, + [refreshTokenHash(generationZero)] + ); + assert.ok(crossGenerationFamily.rows.length === 2 || crossGenerationFamily.rows.length === 3); + assert.equal( + crossGenerationFamily.rows.some(({ status }) => status === "active"), + false, + "family lock prevents an active successor surviving cross-generation replay" + ); + assert.equal( + crossGenerationFamily.rows.every(({ status }) => status === "revoked"), + true + ); + const crossGenerationBearers = await postgresQuery<{ active_count: number }>( + `SELECT COUNT(*) FILTER (WHERE revoked = FALSE)::int AS active_count + FROM tokens + WHERE refresh_family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = $1 + )`, + [refreshTokenHash(generationZero)] + ); + assert.equal(crossGenerationBearers.rows[0]?.active_count, 0, "cross-generation replay leaves no active bearer"); + + const failedReplay = await completeOauthCodeFlow({ asUrl, client, manifest: spotify }); + assert.ok(failedReplay.refreshToken, "replay-fault flow receives generation zero"); + const failedReplayGenerationZero = failedReplay.refreshToken; + const failedReplayRotation = await fetchJson<TokenExchangeBody>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: failedReplayGenerationZero, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(failedReplayRotation.status, 200); + assert.ok(failedReplayRotation.body.refresh_token, "replay-fault flow receives generation one"); + await postgresQuery(` + CREATE OR REPLACE FUNCTION pdpp_test_fail_family_bearer_revoke() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF OLD.revoked = FALSE AND NEW.revoked = TRUE AND NEW.refresh_family_id IS NOT NULL THEN + RAISE EXCEPTION 'injected family bearer revoke failure'; + END IF; + RETURN NEW; + END + $function$ + `); + await postgresQuery(` + CREATE TRIGGER fail_family_bearer_revoke + BEFORE UPDATE ON tokens + FOR EACH ROW EXECUTE FUNCTION pdpp_test_fail_family_bearer_revoke() + `); + try { + const replayFailure = await fetch(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: failedReplayGenerationZero, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.notEqual(replayFailure.status, 200, "failed bearer revoke cannot commit partial family revocation"); + } finally { + await postgresQuery("DROP TRIGGER IF EXISTS fail_family_bearer_revoke ON tokens"); + await postgresQuery("DROP FUNCTION IF EXISTS pdpp_test_fail_family_bearer_revoke() "); + } + const replayFailureFamily = await postgresQuery<{ generation: number; status: string }>( + `SELECT generation, status + FROM oauth_refresh_tokens + WHERE family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = $1 + ) + ORDER BY generation`, + [refreshTokenHash(failedReplayGenerationZero)] + ); + assert.deepEqual( + replayFailureFamily.rows.map(({ generation, status }) => ({ generation, status })), + [ + { generation: 0, status: "superseded" }, + { generation: 1, status: "active" }, + ], + "failed containment rolls the refresh-family revocation back atomically" + ); + const replayFailureBearers = await postgresQuery<{ revoked: boolean }>( + `SELECT revoked + FROM tokens + WHERE refresh_family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = $1 + ) + ORDER BY created_at, token_id`, + [refreshTokenHash(failedReplayGenerationZero)] + ); + assert.deepEqual( + replayFailureBearers.rows.map(({ revoked }) => revoked), + [false, false], + "failed containment rolls bearer revocation back atomically" + ); + const successorAfterFailedReplay = await fetchJson<TokenExchangeBody>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: failedReplayRotation.body.refresh_token, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(successorAfterFailedReplay.status, 200, "rolled-back successor remains usable"); + + const failedIssuance = await completeOauthCodeFlow({ asUrl, client, manifest: spotify }); + assert.ok(failedIssuance.refreshToken, "fault flow receives a refresh token"); + const activeBeforeFailure = await postgresQuery<{ count: number }>( + "SELECT COUNT(*)::int AS count FROM tokens WHERE grant_id = $1 AND revoked = FALSE", + [failedIssuance.grantId] + ); + await postgresQuery(` + CREATE OR REPLACE FUNCTION pdpp_test_fail_refresh_token_event() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF NEW.event_type = 'token.issued' + AND NEW.data_json::jsonb ->> 'issuance_path' = 'oauth_refresh_token' THEN + RAISE EXCEPTION 'injected refresh token event failure'; + END IF; + RETURN NEW; + END + $function$ + `); + await postgresQuery(` + CREATE TRIGGER fail_refresh_token_issued_event + BEFORE INSERT ON spine_events + FOR EACH ROW EXECUTE FUNCTION pdpp_test_fail_refresh_token_event() + `); + try { + const failedRefresh = await fetchJson<TokenExchangeBody>(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: client.client_id, + grant_type: "refresh_token", + refresh_token: failedIssuance.refreshToken, + }).toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + assert.equal(failedRefresh.status, 400); + } finally { + await postgresQuery("DROP TRIGGER IF EXISTS fail_refresh_token_issued_event ON spine_events"); + await postgresQuery("DROP FUNCTION IF EXISTS pdpp_test_fail_refresh_token_event() "); + } + + const failedFamily = await postgresQuery<{ generation: number; status: string }>( + `SELECT generation, status + FROM oauth_refresh_tokens + WHERE family_id = ( + SELECT family_id FROM oauth_refresh_tokens WHERE refresh_token_hash = $1 + ) + ORDER BY generation`, + [refreshTokenHash(failedIssuance.refreshToken)] + ); + assert.deepEqual( + failedFamily.rows.map(({ generation, status }) => ({ generation, status })), + [{ generation: 0, status: "active" }] + ); + const activeAfterFailure = await postgresQuery<{ count: number }>( + "SELECT COUNT(*)::int AS count FROM tokens WHERE grant_id = $1 AND revoked = FALSE", + [failedIssuance.grantId] + ); + assert.equal( + activeAfterFailure.rows[0]?.count, + activeBeforeFailure.rows[0]?.count, + "failed refresh does not leave an active bearer" + ); }); // --------------------------------------------------------------------- @@ -467,7 +1327,7 @@ if (POSTGRES_URL) { // Introspection now reports inactive (tokens SELECT join reads revoked=TRUE). const introspectResp = await fetchJson<IntrospectBody>(`${asUrl}/introspect`, { body: new URLSearchParams({ token: issued.accessToken }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: INTROSPECTION_HEADERS, method: "POST", }); assert.equal(introspectResp.body.active, false, "revoked grant token introspects as inactive"); diff --git a/reference-implementation/test/trusted-owner-agent-rest-boundary.test.ts b/reference-implementation/test/trusted-owner-agent-rest-boundary.test.ts index 8f7b5d7a0..bb47ee31e 100644 --- a/reference-implementation/test/trusted-owner-agent-rest-boundary.test.ts +++ b/reference-implementation/test/trusted-owner-agent-rest-boundary.test.ts @@ -6,9 +6,12 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { registerConnector as registerConnectorCatalog } from "../server/auth.ts"; import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; import { ingestRecord } from "../server/records.ts"; +import { createRequestConnectorInstanceStore } from "../server/request-store-factories.ts"; +import { makeDefaultAccountConnectorInstanceId } from "../server/stores/connector-instance-store.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REFERENCE_IMPL_DIR = join(__dirname, ".."); @@ -126,9 +129,14 @@ async function registerConnector(asUrl: string, manifest: ConnectorManifest): Pr } interface NorthstarManifest { - connector_id: string; + name: string; + source_declaration: { + protocol_version: string; + streams: { name: string }[]; + [key: string]: unknown; + }; storage_binding: { connector_id: string }; - streams: { name: string }[]; + version: string; [key: string]: unknown; } @@ -162,20 +170,49 @@ interface BlobUploadBody { blob_id: string; } -async function seedNorthstar(nativeManifest: NorthstarManifest): Promise<void> { - await ingestRecord(nativeManifest.storage_binding.connector_id, { - data: { - currency: "USD", - employee_id: "emp_123", - employer: "Northstar HR", - gross_pay: 5400, - net_pay: 3912, - statement_id: "ps_owner_agent_1", +async function seedNorthstar(nativeManifest: NorthstarManifest, ownerSubjectId: string): Promise<void> { + const connectorId = nativeManifest.storage_binding.connector_id; + const connectorInstanceId = makeDefaultAccountConnectorInstanceId(ownerSubjectId, connectorId); + const now = new Date().toISOString(); + await registerConnectorCatalog( + { + connector_id: connectorId, + display_name: nativeManifest.name, + protocol_version: nativeManifest.source_declaration.protocol_version, + source_declaration: nativeManifest.source_declaration, + streams: nativeManifest.source_declaration.streams, + version: nativeManifest.version, }, - emitted_at: "2026-05-31T00:00:00Z", - key: "ps_owner_agent_1", - stream: "pay_statements", + { backfillRetrievalIndexes: false } + ); + await createRequestConnectorInstanceStore().upsert({ + connectorId, + connectorInstanceId, + createdAt: now, + displayName: "Northstar HR", + ownerSubjectId, + sourceBinding: { fixture: "trusted-owner-agent-rest-boundary" }, + sourceBindingKey: connectorInstanceId, + sourceKind: "account", + status: "active", + updatedAt: now, }); + await ingestRecord( + { connector_id: connectorId, connector_instance_id: connectorInstanceId }, + { + data: { + currency: "USD", + employee_id: "emp_123", + employer: "Northstar HR", + gross_pay: 5400, + net_pay: 3912, + statement_id: "ps_owner_agent_1", + }, + emitted_at: "2026-05-31T00:00:00Z", + key: "ps_owner_agent_1", + stream: "pay_statements", + } + ); } test("trusted owner-agent bearer reaches owner-visible REST discovery and read surfaces", async () => { @@ -191,28 +228,29 @@ test("trusted owner-agent bearer reaches owner-visible REST discovery and read s const rsUrl = `http://localhost:${server.rsPort}`; try { - await seedNorthstar(nativeManifest); - const ownerToken = await issueOwnerToken(asUrl, "employee_1"); + const ownerSubjectId = "employee_1"; + await seedNorthstar(nativeManifest, ownerSubjectId); + const ownerToken = await issueOwnerToken(asUrl, ownerSubjectId); const authHeaders = { Authorization: `Bearer ${ownerToken}` }; const schema = await fetchJson(`${rsUrl}/v1/schema`, { headers: authHeaders }); assert.equal(schema.status, 200); const streams = await fetchJson<StreamListBody>(`${rsUrl}/v1/streams`, { headers: authHeaders }); - assert.equal(streams.status, 200); + assert.equal(streams.status, 200, JSON.stringify(streams.body)); assert.ok(streams.body.data.some((stream) => stream.name === "pay_statements")); const streamMetadata = await fetchJson(`${rsUrl}/v1/streams/pay_statements`, { headers: authHeaders }); - assert.equal(streamMetadata.status, 200); + assert.equal(streamMetadata.status, 200, JSON.stringify(streamMetadata.body)); const records = await fetchJson<RecordListBody>(`${rsUrl}/v1/streams/pay_statements/records?limit=1`, { headers: authHeaders, }); - assert.equal(records.status, 200); + assert.equal(records.status, 200, JSON.stringify(records.body)); assert.equal(records.body.data?.[0]?.id, "ps_owner_agent_1"); const search = await fetchJson(`${rsUrl}/v1/search?q=Northstar&limit=1`, { headers: authHeaders }); - assert.equal(search.status, 200); + assert.equal(search.status, 200, JSON.stringify(search.body)); } finally { await closeServer(server); } diff --git a/scripts/cli-acceptance-smoke.ts b/scripts/cli-acceptance-smoke.ts index aea10ace8..6db9170dc 100644 --- a/scripts/cli-acceptance-smoke.ts +++ b/scripts/cli-acceptance-smoke.ts @@ -9,8 +9,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import process from "node:process"; -import { extractCsrfFieldValue, findSetCookiePair, getSetCookieList } from "./lib/owner-session.ts"; - const [, , mode] = process.argv; const repoRoot = new URL("..", import.meta.url).pathname; const cliBin = join(repoRoot, "packages/cli/bin/pdpp.ts"); @@ -250,31 +248,53 @@ async function approveAccess(approvalUrl: string): Promise<void> { if (!requestUri) { throw new Error(`FAIL approval URL missing request_uri: ${approvalUrl}`); } - const consentPage = await fetch(url, { - headers: { Accept: "text/html" }, - redirect: "manual", + + const reviewResponse = await fetch(new URL("/consent/review", url), { + body: JSON.stringify({ request_uri: requestUri }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST", }); - const csrfCookie = findSetCookiePair(getSetCookieList(consentPage), "pdpp_owner_csrf"); - const csrfField = extractCsrfFieldValue(await consentPage.text()); - const approveBody: Record<string, string> = { request_uri: requestUri, subject_id: "owner_local" }; - if (csrfField) { - approveBody._csrf = csrfField; + const reviewText = await reviewResponse.text(); + let reviewBody: unknown; + try { + reviewBody = JSON.parse(reviewText); + } catch (error) { + throw new Error(`FAIL consent review returned non-JSON (${reviewResponse.status}): ${reviewText}`, { + cause: error, + }); } - const approveHeaders: Record<string, string> = { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", + if (!reviewResponse.ok) { + throw new Error(`FAIL consent review failed: HTTP ${reviewResponse.status}: ${reviewText}`); + } + if (!reviewBody || typeof reviewBody !== "object" || Array.isArray(reviewBody)) { + throw new Error("FAIL consent review returned a non-object body"); + } + const review = reviewBody as { + approval_review?: unknown; + approval_review_revision?: unknown; + request_uri?: unknown; }; - if (csrfCookie) { - approveHeaders.Cookie = csrfCookie; + if (!review.approval_review || typeof review.approval_review !== "object" || Array.isArray(review.approval_review)) { + throw new Error("FAIL consent review returned without the exact approval artifact"); + } + if (typeof review.approval_review_revision !== "string" || !review.approval_review_revision) { + throw new Error("FAIL consent review returned without approval_review_revision"); } + if (review.request_uri !== requestUri) { + throw new Error("FAIL consent review returned a different canonical request_uri"); + } + const response = await fetch(new URL("/consent/approve", url), { + body: JSON.stringify({ + approval_review_revision: review.approval_review_revision, + request_uri: review.request_uri, + }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, method: "POST", - headers: approveHeaders, - body: new URLSearchParams(approveBody).toString(), - redirect: "manual", }); - if (!(response.ok || response.status === 302 || response.status === 303)) { - throw new Error(`FAIL test approval failed: HTTP ${response.status}`); + if (!response.ok) { + const text = await response.text(); + throw new Error(`FAIL consent approval failed: HTTP ${response.status}: ${text}`); } } diff --git a/scripts/railway-mcp-query-smoke.ts b/scripts/railway-mcp-query-smoke.ts index 209f8432e..fe1e5ca02 100644 --- a/scripts/railway-mcp-query-smoke.ts +++ b/scripts/railway-mcp-query-smoke.ts @@ -302,6 +302,27 @@ async function readBody(resp: Response): Promise<ReadBodyResult> { return { text, json }; } +function reviewedConsent(body: unknown, requestUri: string): { requestUri: string; revision: string } { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new SmokeError("consent review returned a non-object body"); + } + const review = body as { + approval_review?: unknown; + approval_review_revision?: unknown; + request_uri?: unknown; + }; + if (!review.approval_review || typeof review.approval_review !== "object" || Array.isArray(review.approval_review)) { + throw new SmokeError("consent review returned without the exact approval artifact"); + } + if (typeof review.approval_review_revision !== "string" || !review.approval_review_revision) { + throw new SmokeError("consent review returned without approval_review_revision"); + } + if (review.request_uri !== requestUri) { + throw new SmokeError("consent review returned a different canonical request_uri"); + } + return { requestUri, revision: review.approval_review_revision }; +} + type LogFn = (message: string) => void; // Establish an owner session via the shared owner-session helper @@ -545,12 +566,33 @@ async function mintClientToken(origin: string, sessionCookie: string, log: LogFn const consentCsrfCookie = findSetCookiePair(getSetCookieList(consentPageResp), "pdpp_owner_csrf"); const consentCsrfField = extractCsrfFieldValue(await consentPageResp.text()); - const approveHeaders: Record<string, string> = { "Content-Type": "application/x-www-form-urlencoded" }; + const reviewResp = await fetch(`${origin}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri }), + headers: { + Accept: "application/json", + "Content-Type": "application/json", + ...(sessionCookie ? { Cookie: sessionCookie } : {}), + }, + method: "POST", + }); + const reviewResult = await readBody(reviewResp); + if (!reviewResp.ok) { + throw new SmokeError(`consent/review failed ${reviewResp.status}: ${reviewResult.text}`); + } + const review = reviewedConsent(reviewResult.json, requestUri); + + const approveHeaders: Record<string, string> = { + Accept: "text/html", + "Content-Type": "application/x-www-form-urlencoded", + }; const cookieParts = [sessionCookie, consentCsrfCookie].filter(Boolean); if (cookieParts.length > 0) { approveHeaders.Cookie = cookieParts.join("; "); } - const approveBody: Record<string, string> = { request_uri: requestUri, subject_id: "owner_railway_smoke" }; + const approveBody: Record<string, string> = { + approval_review_revision: review.revision, + request_uri: review.requestUri, + }; if (consentCsrfField) { approveBody._csrf = consentCsrfField; } diff --git a/scripts/read-surface-smoke.ts b/scripts/read-surface-smoke.ts index 69aa53af8..71b25fb97 100644 --- a/scripts/read-surface-smoke.ts +++ b/scripts/read-surface-smoke.ts @@ -780,6 +780,20 @@ async function readBody(resp: Response): Promise<{ json: JsonValue; text: string return { text, json }; } +function reviewedConsent(body: JsonValue, requestUri: string): { requestUri: string; revision: string } { + const review = jsonRecord(body); + if (!(review && jsonRecord(review.approval_review))) { + throw new Error("consent review returned without the exact approval artifact"); + } + if (typeof review.approval_review_revision !== "string" || !review.approval_review_revision) { + throw new Error("consent review returned without approval_review_revision"); + } + if (review.request_uri !== requestUri) { + throw new Error("consent review returned a different canonical request_uri"); + } + return { requestUri, revision: review.approval_review_revision }; +} + function pkceChallenge(verifier: string): string { return crypto.createHash("sha256").update(verifier).digest("base64url"); } @@ -881,12 +895,33 @@ async function mintScopedClientToken({ const consentCsrfCookie = findSetCookiePair(getSetCookieList(consentPageResp), "pdpp_owner_csrf"); const consentCsrfField = extractCsrfFieldValue(await consentPageResp.text()); - const approveHeaders: Record<string, string> = { "Content-Type": "application/x-www-form-urlencoded" }; + const reviewResp = await fetch(`${origin}/consent/review`, { + body: JSON.stringify({ request_uri: requestUri, subject_id: ownerSubject || "owner_local" }), + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Cookie: sessionCookie, + }, + method: "POST", + }); + const reviewResult = await readBody(reviewResp); + if (!reviewResp.ok) { + throw new Error(`consent/review failed ${reviewResp.status}: ${reviewResult.text}`); + } + const review = reviewedConsent(reviewResult.json, requestUri); + + const approveHeaders: Record<string, string> = { + Accept: "text/html", + "Content-Type": "application/x-www-form-urlencoded", + }; const cookieParts = [sessionCookie, consentCsrfCookie].filter(Boolean) as string[]; if (cookieParts.length > 0) { approveHeaders.Cookie = cookieParts.join("; "); } - const approveBody: Record<string, string> = { request_uri: requestUri, subject_id: ownerSubject || "owner_local" }; + const approveBody: Record<string, string> = { + approval_review_revision: review.revision, + request_uri: review.requestUri, + }; if (consentCsrfField) { approveBody._csrf = consentCsrfField; } diff --git a/scripts/test-accounting/receipt.ts b/scripts/test-accounting/receipt.ts index e9d8549c4..9adc86ac5 100644 --- a/scripts/test-accounting/receipt.ts +++ b/scripts/test-accounting/receipt.ts @@ -75,7 +75,7 @@ export const POSTGRES_UNNAMED_SKIP_TEST_NAME_ROWS: readonly string[] = [ "Postgres clears stale profile provenance on a profile-key change and accepts an explicit replacement", "Postgres connector-summary evidence reaches the same rebuild/dirty/reconcile shape", "Postgres ground-truth streams + for-keys produce the same shaped facts as SQLite", - "Postgres introspection fails closed on an unexpected manifest-storage fault", + "Postgres introspection keeps the issued declaration snapshot authoritative", "Postgres migrates legacy accepted outcomes to equal named terminal cursor facts", "Postgres pool saturation and unlock uncertainty destroy the lock session", "Postgres preserves new run identity and rejects an unbound writer", diff --git a/skills/pdpp-data-access/references/grant-design.md b/skills/pdpp-data-access/references/grant-design.md index fe701b0c3..51f884e3e 100644 --- a/skills/pdpp-data-access/references/grant-design.md +++ b/skills/pdpp-data-access/references/grant-design.md @@ -9,13 +9,13 @@ one `authorization_details[]` entry per PAR request, and remains the default agent workflow: one source, one request, one grant. The reference also ships a **reference-experimental** batch path that stages several source-bounded entries in one ceremony, plus parent-linked add-source ceremonies that may stage exactly -one added source — see "Reference-experimental batch consent" below. Parentless +one added source - see "Reference-experimental batch consent" below. Parentless single-entry requests still use the default path. One entry has: | Field | Meaning | Common values | | --- | --- | --- | | `type` | Grant family | `"https://pdpp.dev/data-access"` for read access | -| `source` | Which source | `{ "kind": "connector", "id": "https://registry.pdpp.dev/connectors/github" }` or `{ "kind": "provider_native", "id": "northstar_hr" }` | +| `source` | Which source | `{ "kind": "connector", "id": "https://registry.pdpp.dev/connectors/github" }` or `{ "kind": "provider_native", "id": "https://northstar.example/sources/hr" }` | | `purpose_code` | Coarse intent | `assist.summarize`, `assist.review`, `assist.search`, `assist.draft`, `assist.export` | | `purpose_description` | Owner-readable why | One sentence, plain English, scoped to the task | | `access_mode` | Access pattern | `single_use`, `continuous` | @@ -28,7 +28,7 @@ Set exactly one source object. The reference will reject legacy top-level `conne ### Source -- Use the *narrowest* source that contains the data. If both `gmail` and a generic `mail` connector exist, prefer the specific one — its manifest is usually tighter. +- Use the *narrowest* source that contains the data. If both `gmail` and a generic `mail` connector exist, prefer the specific one - its manifest is usually tighter. - A "search across all my data" intent is almost never legitimate as one grant. Split the task by source. - Older docs may call connector sources `connector_id` and native sources `provider_id`; those names now map to `source.id` under the matching `source.kind`. @@ -36,11 +36,11 @@ Set exactly one source object. The reference will reject legacy top-level `conne Stable, machine-readable. The reference accepts any string today, but you should pick from the assistant-task family so the consent UI can group them sensibly: -- `assist.summarize` — produce a digest the user reads. -- `assist.review` — flag/triage items for the user. -- `assist.search` — find specific items the user named. -- `assist.draft` — produce content the user will edit and send. -- `assist.export` — copy data into a user-owned destination they will use elsewhere. +- `assist.summarize` - produce a digest the user reads. +- `assist.review` - flag/triage items for the user. +- `assist.search` - find specific items the user named. +- `assist.draft` - produce content the user will edit and send. +- `assist.export` - copy data into a user-owned destination they will use elsewhere. Avoid `assist.train`, `assist.export.third_party`, `assist.improve_model` etc. They imply retention or third-party flow that this skill does not support and that the consent UI cannot honestly approve. @@ -85,34 +85,41 @@ PAR=$(curl -sX POST $AS_URL/oauth/par \ }') REQUEST_URI=$(echo $PAR | jq -r .request_uri) -# 2. Owner approves — this creates the grant AND issues the first (and only) token. -# The grant is marked consumed atomically. -APPROVED=$(curl -sX POST $AS_URL/consent/approve \ +# 2. Owner reviews the exact artifact to approve. +REVIEW=$(curl -sX POST $AS_URL/consent/review \ -H 'Content-Type: application/json' \ -d "{\"request_uri\": \"$REQUEST_URI\", \"subject_id\": \"owner_local\"}") +REVIEW_REVISION=$(echo $REVIEW | jq -r .approval_review_revision) + +# 3. Owner approves by revision only. This creates the grant AND issues the first +# (and only) token. The grant is marked consumed atomically. +APPROVED=$(curl -sX POST $AS_URL/consent/approve \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -d "{\"request_uri\": \"$REQUEST_URI\", \"approval_review_revision\": \"$REVIEW_REVISION\"}") TOKEN=$(echo $APPROVED | jq -r .token) GRANT_ID=$(echo $APPROVED | jq -r .grant.grant_id) -# 3. First RS query succeeds — the issued token is valid until expiry. +# 4. First RS query succeeds - the issued token is valid until expiry. curl -s "$RS_URL/v1/streams/top_artists/records?limit=1" \ -H "Authorization: Bearer $TOKEN" # → HTTP 200 { "data": [...], ... } -# 4. The grant is now consumed. Introspection confirms active=true (token valid) +# 5. The grant is now consumed. Introspection confirms active=true (token valid) # but a second token issuance attempt for the same grant_id is rejected. # The reference implementation enforces this at the AS layer: any call to # issueToken() with a consumed grant_id throws { code: "grant_consumed" }. # In the standard device-code or PKCE token exchange, the AS returns: # HTTP 400 { "error": "invalid_grant", "error_description": "Grant has already been consumed" } -# 5. Continuous grants are NOT consumed — repeated token issuances succeed. +# 6. Continuous grants are NOT consumed - repeated token issuances succeed. # Run the same flow with "access_mode": "continuous" and the second issuance # returns a fresh token instead of 400. ``` -**What the enforcement looks like:** `POST /consent/approve` calls `issueToken()` internally. +**What the enforcement looks like:** `POST /consent/review` returns the exact artifact and revision. `POST /consent/approve` accepts the revision, not stream or field choices, then calls `issueToken()` internally. `issueToken()` runs an atomic `SELECT … FOR UPDATE` / `UPDATE grants SET consumed = TRUE` in a -single transaction — the check and the mark are one unit. A concurrent second call races on the +single transaction - the check and the mark are one unit. A concurrent second call races on the same row and loses; it reads `consumed = 1` and throws `grant_consumed` before any token row is written. The HTTP boundary surfaces this as `invalid_grant` (RFC 6749 §5.2) with `error_description: "Grant has already been consumed"`. @@ -129,8 +136,8 @@ If you need a relationship (e.g., Gmail messages with message bodies), prefer th ### What *not* to put in the grant -- `client_secret` — you are using `token_endpoint_auth_method: "none"` for public clients; there is no secret. -- Owner email, owner subject id, or any owner identifier — the AS resolves the owner from the session. +- `client_secret` - you are using `token_endpoint_auth_method: "none"` for public clients; there is no secret. +- Owner email, owner subject id, or any owner identifier - the AS resolves the owner from the session. - Free-form retention policies (`"keep_for_days": 90`). The reference does not honor them today; including them gives a false sense of control. If the user wants retention, that's a project-side rule, not a grant field. ## Patterns @@ -167,7 +174,7 @@ Grant A: source={kind: connector, id: https://registry.pdpp.dev/connectors/gmail Grant B: source={kind: connector, id: https://registry.pdpp.dev/connectors/ical}, streams=[events], time_range=next 24h ``` -Don't try to bundle these into one `authorization_details[]` array entry — the reference treats one entry as one source binding. (If you genuinely need several sources set up in one owner sitting, see the reference-experimental batch path below; it still issues one independent grant per source.) +Don't try to bundle these into one `authorization_details[]` array entry - the reference treats one entry as one source binding. (If you genuinely need several sources set up in one owner sitting, see the reference-experimental batch path below; it still issues one independent grant per source.) ### Reference-experimental batch consent @@ -199,8 +206,9 @@ What the owner ceremony does, and what you get back: - **One ceremony, per-source review.** The owner sees one review card per source plus a cumulative-risk header (sensitive-source, continuous-access, no-time-bound, no-field-projection, and total-stream counts across the batch). - **Per-source decisions.** The owner can approve, deny, defer, or narrow each source independently. Approving a subset issues grants for only the approved sources. The owner can narrow a source (drop streams, reduce fields, tighten a time range); you cannot widen beyond what you staged. +- **Reviewed artifact before approval.** `POST /consent/review` freezes the final batch decision and returns `approval_review_revision`. Final `POST /consent/approve` sends `request_uri`, that revision, and `confirm_reviewed_decision`; it must not submit source choices again. - **One access mode per batch.** Every entry in one batch request must declare the same `access_mode`. If you need different modes for different sources, run separate ceremonies. -- **Independent grants.** Approval issues one independent, source-bounded, individually revocable grant per approved source — the same grant object the single-source path produces. There is no cross-source grant. +- **Independent grants.** Approval issues one independent, source-bounded, individually revocable grant per approved source - the same grant object the single-source path produces. There is no cross-source grant. - **Package grouping.** The issued grants are grouped under a `package_id` for audit and timeline. `package_id` is grouping/audit metadata only; record access is still authorized solely by the active child grants. Per-grant revocation stays primary; a revoke-package convenience dispatches one revoke per child and reports partial failure honestly. #### Incremental add-source (`parent_package_id`) @@ -219,7 +227,7 @@ and set a top-level `parent_package_id` to the prior package: ``` - The new ceremony creates a new package linked to the prior one and issues independent grants **only for the added sources**. It never re-issues or mutates the prior package's grants. -- `parent_package_id` is lineage/cumulative-view metadata, not a new authorization primitive — it grants nothing on its own. +- `parent_package_id` is lineage/cumulative-view metadata, not a new authorization primitive - it grants nothing on its own. - Linkage must be to one of *your own* still-active packages for the same owner. A missing, cross-client, cross-owner, inactive, or malformed `parent_package_id` is rejected before any grant is issued. - The owner-facing dashboard can render the cumulative per-client view across linked packages (reference surface: `GET /_ref/grant-packages/:id/cumulative`). - `parent_package_id` is the signal for the staged add-source path, even when you are adding exactly one source. Without `parent_package_id`, a single-entry request remains the default one-grant path. @@ -238,7 +246,7 @@ Two grants now exist, the user can revoke the upgrade alone, and the audit trail After `pdpp connect` or `POST /consent/approve`, you can inspect any live token against the AS to confirm it is active and read back the full grant it encodes. -This is the authoritative check — it re-runs the grant-contract validation on +This is the authoritative check - it re-runs the grant-contract validation on each call. ```bash @@ -274,12 +282,12 @@ A healthy active client token returns: Key verification points: -- `active: true` — token is valid and the underlying grant is still active. -- `pdpp_token_kind` — `"client"` for grant-scoped tokens, `"owner"` for self-export tokens. -- `grant_id` — confirms which grant backs this token. -- `grant.streams[].resources` — present and populated only when the grant was +- `active: true` - token is valid and the underlying grant is still active. +- `pdpp_token_kind` - `"client"` for grant-scoped tokens, `"owner"` for self-export tokens. +- `grant_id` - confirms which grant backs this token. +- `grant.streams[].resources` - present and populated only when the grant was scoped to specific record keys (see "Record-scoped access with resources[]" below). -- `grant_storage_binding` is **never present** in the public response — the AS +- `grant_storage_binding` is **never present** in the public response - the AS redacts the internal storage connector id before returning the envelope. If a grant has been consumed (`single_use`) or revoked, the token will still exist @@ -295,7 +303,7 @@ Possible `inactive_reason` values: `grant_revoked`, `grant_expired`, `token_revo ## Record-scoped access with `resources[]` -`resources[]` on a stream entry restricts a grant to specific record keys — an +`resources[]` on a stream entry restricts a grant to specific record keys - an RFC 8707-style audience binding at the record level. The RS enforces this as a SQL `WHERE record_key IN (...)` predicate; records outside the list are invisible to that token even if they exist in the store. @@ -312,7 +320,7 @@ to that token even if they exist in the store. Use `resources[]` when the user explicitly named the items they want to share ("just those three invoices", "only the two pull requests I linked"). Do not use -it for time-bounded or field-projected access — that is what `time_range` and +it for time-bounded or field-projected access - that is what `time_range` and `fields` are for. An empty `resources[]` array is equivalent to omitting the field (all records visible within the other grant constraints). diff --git a/spec-architecture.md b/spec-architecture.md index 541f253c9..58fc4b581 100644 --- a/spec-architecture.md +++ b/spec-architecture.md @@ -114,7 +114,7 @@ Event-driven ingestion (a platform pushes events to the personal server) is defe |-----------|----------------------|-------| | Grant object | **Yes** | The parameterized consent artifact (Core Section 7) | | Record model | **Yes** | Streams, schemas, keys, blob_ref, resource_ref (Core Section 4) | -| Source binding | **Yes** | `source: { kind, id }` on requests and grants (Core Section 6) | +| Source binding | **Yes** | Requests use `source: { id, kind? }`; grants use `source: { kind, id }` (Core Sections 6-7) | | Connector manifest | **Yes** | What a connector produces and requires (Core Section 5) | | Connector run protocol | **Yes** | START/RECORD/STATE/INTERACTION/DONE (Collection Profile) | | Selection request format | **Yes** | RFC 9396 authorization_details (Core Section 6) | diff --git a/spec-core.md b/spec-core.md index 249cbe3aa..048d4c5fd 100644 --- a/spec-core.md +++ b/spec-core.md @@ -43,7 +43,7 @@ Sections 4-8 define the protocol surfaces that implementations evaluate independ | [OAuth 2.0](https://www.rfc-editor.org/rfc/rfc6749) (RFC 6749) | PDPP is a profile of OAuth 2.0, carrying selection requests in RFC 9396 authorization_details. The grant is issued as the result of an OAuth authorization flow. | | [RFC 9396](https://www.rfc-editor.org/rfc/rfc9396) (RAR) | PDPP uses the `authorization_details` envelope for selection requests. The `type` URI is `https://pdpp.dev/data-access`. | | [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) (Bearer Token) | PDPP transports both owner tokens and client tokens as RFC 6750 Bearer Tokens on the wire. The resource server distinguishes token kind via `pdpp_token_kind` in the introspection response, not by token syntax. | -| [RFC 7662](https://www.rfc-editor.org/rfc/rfc7662) (Token Introspection) | PDPP relies on RFC 7662-style token introspection where the authorization server and resource server are separated, so the resource server can resolve grant-bound tokens. Co-located deployments may use a local equivalent. | +| [RFC 7662](https://www.rfc-editor.org/rfc/rfc7662) (Token Introspection) | PDPP uses authenticated RFC 7662 token introspection where the authorization server and resource server are separated, so the resource server can resolve grant-bound tokens. Co-located deployments may use a local equivalent. | | [OAuth 2.0 Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591) (RFC 7591) | PDPP reuses the RFC 7591 client metadata vocabulary (`client_name`, `logo_uri`, `policy_uri`, and similar fields) for the consent display. A dynamic client registration endpoint is a deployment choice and is required only where deployments need it; Core functions without it. | | [SMART on FHIR](https://hl7.org/fhir/smart-app-launch/) | Follows the domain-profile-over-OAuth pattern PDPP adopts: OAuth handles authorization, and the profile adds a domain data model, consent semantics, and a conformance regime. SMART on FHIR reached ubiquity through regulatory adoption of SMART-on-FHIR-patterned API requirements (the ONC Cures Act rule). | | [UK Open Banking](https://www.openbanking.org.uk/standards/) | Also follows the domain-profile-over-OAuth pattern PDPP adopts: OAuth handles authorization, and the profile adds a domain data model, consent semantics, and a conformance regime. UK Open Banking reached ubiquity through the CMA's Open Banking mandate for the largest UK banks. | @@ -91,7 +91,7 @@ In many deployments, a single **personal server** fills all three roles. The spe **Note on the Authorization Server interface:** This spec defines the resource server interface normatively because cross-deployment interoperability requires it: a client written against the interface works with any conformant resource server regardless of who operates it or where data lives. The authorization server interface is not normatively specified in v0.1 because user-facing authorization flows are deployment-specific. The reference implementation uses the OAuth authorization code flow with RFC 9396 authorization_details for client grants, and OAuth device authorization for owner tokens. -**Token resolution:** User-facing authorization flows are deployment-specific and are not normatively specified in v0.1. However, when the AS and RS are deployed separately, the AS↔RS token-resolution contract is normative: the RS resolves access tokens using RFC 7662-style token introspection. For co-located deployments, a local equivalent (shared database or function call) is acceptable. Self-contained JWTs may be used as an optimization but MUST NOT be the sole revocation mechanism (see Section 10). +**Token resolution:** User-facing authorization flows are deployment-specific and are not normatively specified in v0.1. However, when the AS and RS are deployed separately, the AS-to-RS token-resolution contract is normative: the RS MUST authenticate to the RFC 7662 introspection endpoint and resolve the complete grant enforcement context from its response. The RS MUST enforce the request from that response and MUST NOT make a second AS lookup. For co-located deployments, a local equivalent (shared database or function call) is acceptable. Self-contained JWTs may be used as an optimization but MUST NOT be the sole revocation mechanism (see Section 10). ### Data concepts @@ -906,6 +906,13 @@ Three independent version axes exist in PDPP. They MUST NOT be conflated: | Source declaration revision | `grant.source_declaration.version` | Identifies the exact retained declaration snapshot used for consent and issuance. It is opaque evidence metadata. The RS enforces the resolved grant and does not fetch that revision for authorization. | | HTTP API contract version | `PDPP-Version` request header | Version of the RS HTTP API contract. RS returns 400 `unsupported_version` if the requested version is not supported. If the header is absent, the RS uses the current stable version and returns the selected version in the response header (see [Section 8](#resource-server-interface)). | +The current persisted-authorization-state reader MUST reject any persisted +authorization state whose version or shape it cannot validate against a +supported contract before its caller continues introspection or route +handling. The reader MUST NOT reconstruct missing authorization or binding +facts from current configuration. A deployment that cannot support or +explicitly migrate such state MUST require fresh consent. + ### Access modes {#access-modes} | Mode | Behavior | @@ -1052,7 +1059,7 @@ The resource server stores records and serves them to clients filtered by grants On every request, the resource server: -1. Resolves the access token via token introspection (RFC 7662-style) or a local equivalent for co-located deployments. Positive introspection results MUST NOT be cached longer than `min(token_exp, 60 seconds)`. +1. Resolves the access token through authenticated RFC 7662 introspection or a local equivalent for co-located deployments. Positive introspection results MUST NOT be cached longer than `min(token_exp, 60 seconds)`. 2. Verifies that the grant is active (`active: true` in the introspection response). 3. Verifies that the requested stream appears in the grant's `streams` list. 4. Selects records only from the explicitly granted `instance_ids` and verifies that the request falls within the grant's `time_constraint`, `fields`, and `resources` constraints. @@ -1070,7 +1077,9 @@ The RS MUST NOT re-validate authorization against the current SourceDeclaration. ### Token introspection -For separated AS/RS deployments, the RS calls the AS introspection endpoint (RFC 7662). PDPP defines the following extension fields in the introspection response: +For separated AS/RS deployments, the RS MUST authenticate to the AS +introspection endpoint as required by RFC 7662. PDPP defines the following +extension fields in the introspection response: | Field | Type | Description | |-------|------|-------------| @@ -1079,7 +1088,13 @@ For separated AS/RS deployments, the RS calls the AS introspection endpoint (RFC | `subject_id` | string | The subject (user) identifier. | | `grant_id` | string | The associated grant identifier. Present for client tokens. | | `client_id` | string | The client identifier. Present for client tokens. | -| `exp` | integer | Expiry timestamp (Unix epoch). | +| `exp` | integer | Optional expiry timestamp (Unix epoch). Omitted when the token has no expiration. | +| `authorization_details` | array | The approved RFC 9396 detail for a client token. It carries the resolved grant enforcement constraints defined in Section 7. | + +The introspection response MUST contain the complete context needed to enforce +the request. The separated RS MUST enforce only from that response and MUST +NOT make a second AS lookup while handling the request. A co-located AS and RS +MAY resolve the same context through a local equivalent. **Token kind extensibility:** This specification defines `owner` and `client`. Deployments MAY introduce additional token kinds in companion profiles. A resource server that receives a `pdpp_token_kind` value it does not recognize MUST treat the token as unauthorized for all operations defined in this specification. @@ -1378,6 +1393,7 @@ Every non-2xx response returns a structured error: | `unknown_field` | 400 | `invalid_request_error` | Requested field not in stream schema. | | `unsupported_version` | 400 | `invalid_request_error` | `PDPP-Version` header specifies unsupported version, or grant references unsupported schema version. | | `authentication_error` | 401 | `authentication_error` | Missing or invalid access token. | +| `authorization_state.unsupported_legacy_shape` | 401 | `authentication_error` | Persisted authorization state does not match a supported shape. Fresh consent is required when no migration applies. | | `field_not_granted` | 403 | `permission_error` | Filter targets a field outside the grant's authorized projection. | | `insufficient_scope` | 403 | `permission_error` | Expansion requests a stream not in the grant. | | `grant_stream_not_allowed` | 403 | `permission_error` | Stream not in grant. | @@ -1435,6 +1451,10 @@ A conformant authorization server: reviewed revision changes before approval. 16. Retains one exact SourceDeclaration snapshot through request validation, consent display, narrowing, issuance, and consent evidence. A later current declaration never substitutes for it. 17. Returns 400 `unsupported_version` if `PDPP-Version` header specifies an unsupported version. +18. For a separated AS and RS, authenticates the RS at the RFC 7662 introspection endpoint and returns the complete grant enforcement context in one response. +19. Consumes each OAuth authorization code atomically on its first successful redemption. Rejects every later redemption with `invalid_grant` and does not issue another token. +20. Issues refresh tokens only for `continuous` grants, or for a grant package only when every child grant is `continuous`. It rotates refresh tokens by family. Reuse of a superseded token revokes the family and every family-linked access token, returns `invalid_grant`, and requires fresh authorization. +21. Rejects unsupported persisted authorization state before introspection or request handling. Does not reconstruct missing facts from current configuration and requires fresh consent when no migration applies. ### Resource Server conformance @@ -1442,7 +1462,7 @@ A conformant Core RS: 1. Implements the query endpoints defined in Section 8: list streams, get stream metadata, list records, get a single record, get a blob, delete a record (owner-authenticated). 2. Enforces grant constraints on every client request: stream membership, explicit instance handles, frozen `time_constraint`, `fields` allowlist, and `resources` filter. -3. Resolves access tokens via introspection (RFC 7662) or local equivalent. Caches positive introspection results no longer than `min(token_exp, 60 seconds)`. +3. In a separated deployment, resolves access tokens through authenticated RFC 7662 introspection, enforces only from that response, and makes no second AS lookup while handling the request. A co-located deployment may use a local equivalent. Caches positive results no longer than `min(token_exp, 60 seconds)`. 4. Distinguishes owner tokens from client tokens via `pdpp_token_kind`. 5. Computes effective filters as `grant_filter AND request_filter`. 6. Returns structured errors as defined in Section 8 (unified error table). @@ -1482,11 +1502,40 @@ A formal conformance test suite is planned but is not defined in v0.1. This is o PDPP defines two token kinds at the resource server boundary: owner tokens and client tokens. Both use RFC 6750 Bearer Token format on the wire. The RS distinguishes them via `pdpp_token_kind` in the introspection response, not by token syntax. -For separated AS/RS deployments, the RS calls the AS introspection endpoint (RFC 7662). For co-located deployments, a local equivalent (shared database lookup or function call) is acceptable. Self-contained JWTs are allowed as an optimization but MUST NOT be the sole revocation mechanism. +For separated AS/RS deployments, the RS MUST authenticate to the AS introspection endpoint (RFC 7662) and enforce only from its response. It MUST NOT make a second AS lookup while handling the request. For co-located deployments, a local equivalent (shared database lookup or function call) is acceptable. Self-contained JWTs are allowed as an optimization but MUST NOT be the sole revocation mechanism. Positive introspection results MUST NOT be cached longer than `min(token_exp, 60 seconds)`. This bounds the propagation window for revocation. -Implementations SHOULD use short-lived access tokens with refresh tokens for `continuous` grants. +An access token issued with or from a refresh-token family MUST be linked to +that family and MUST have a short, token-specific expiration no later than the +family or grant expiration. A token response MUST derive `expires_in` from the +access token's persisted expiration. It MUST omit `expires_in` when the access +token has no expiration. An RFC 7662 response MUST likewise omit `exp` when no +expiration exists. + +Every successful OAuth token response that contains an access token or refresh +token MUST include `Cache-Control: no-store` and `Pragma: no-cache` before the +response is serialized. This applies to authorization-code, refresh-token, and +device-code exchanges, including package-scoped variants. + +An authorization code MUST be consumed atomically on its first successful +redemption. A later redemption, including one with the same valid PKCE +verifier, MUST return `invalid_grant` and MUST NOT issue another token. + +When an authorization server issues refresh tokens for a `continuous` grant, +each token MUST belong to a family and MUST rotate after successful use. The +AS MUST atomically supersede the presented token and issue one active +successor. Reuse of any superseded token, including a retry after a lost +successful response, MUST revoke the token family and every access token linked +to that family, return `invalid_grant`, and require fresh authorization. +Introspection MUST report every family-linked access token inactive after the +replay is detected. An AS MUST NOT issue refresh tokens for a `single_use` +grant. It MUST NOT issue one for a grant package unless every child grant is +`continuous`. On upgrade, an implementation MUST NOT infer family linkage for +an existing bearer. Any live refresh family without persisted bearer linkage +MUST be revoked together with its grant- or package-bound bearer tokens and +MUST require fresh authorization. This behavior follows +[RFC 9700](https://www.rfc-editor.org/rfc/rfc9700), Section 4.14.2. **Sender-constrained tokens (non-normative):** Bearer tokens (RFC 6750) are the v0.1 baseline. Deployments handling sensitive standing access SHOULD consider sender-constrained tokens, which bind a token to a client-held key so that possession of the token alone is not sufficient to use it. DPoP (RFC 9449) and mutual-TLS certificate binding (RFC 8705) are both compatible with PDPP's introspection-based design. A formal optional hardening profile is a candidate for a future version. @@ -1800,6 +1849,7 @@ interface PDPPIntrospectionResponse { grant_id?: string; // Present for client tokens client_id?: string; // Present for client tokens exp?: number; // Unix timestamp + authorization_details?: Array<Record<string, unknown>>; // Approved RFC 9396 detail with Section 7 enforcement constraints } // --- Tombstone (response object) --- diff --git a/spec-deferred.md b/spec-deferred.md index 0f2b9bc26..7eedd5450 100644 --- a/spec-deferred.md +++ b/spec-deferred.md @@ -202,11 +202,11 @@ _Previously deferred (carried forward): concerns that constrain semantic choices **Design constraint for v0.1:** Keep `retention` in the spec but document it as a structured policy field, not a technical control. This is consistent with how Open Banking handles it. -### Source-binding unification (`connector_id`/`provider_id` → `source: { kind, id }`) +### Source-binding unification (`connector_id`/`provider_id` → `source`) _Recorded 2026-07-06; change implemented 2026-04-30._ -Earlier drafts of spec-core defined a top-level `connector_id` scalar (and the reference contract a sibling `provider_id`) as the request/grant source-identity field. These were unified into the single discriminated `source: { kind: "connector" | "provider_native", id }` object. This was a breaking change to the request and grant contract, implemented via the archived OpenSpec change `2026-04-30-unify-source-binding-vocabulary`. The former scalars survive only as the kind-keyed meanings of `source.id`, never as top-level request or grant fields; a request carrying a top-level `connector_id` or `provider_id` is rejected with 400 `invalid_request`. The spec-core text was aligned with the implemented contract on 2026-07-06. +Earlier drafts of spec-core defined a top-level `connector_id` scalar (and the reference contract a sibling `provider_id`) as the request/grant source-identity field. These were unified into a `source` object. A request requires `source.id` and may supply `source.kind`; a resolved grant requires both. This was a breaking change to the request and grant contract, implemented via the archived OpenSpec change `2026-04-30-unify-source-binding-vocabulary`. The former scalars survive only as kind-keyed meanings of `source.id`, never as top-level request or grant fields; a request carrying a top-level `connector_id` or `provider_id` is rejected with 400 `invalid_request`. The spec-core text was aligned with the implemented contract on 2026-07-06. ### Stream dependencies and binary data diff --git a/spec-discovery-and-trust.md b/spec-discovery-and-trust.md new file mode 100644 index 000000000..a29a5285f --- /dev/null +++ b/spec-discovery-and-trust.md @@ -0,0 +1,151 @@ +# PDPP Source Declaration Discovery and Trust v0.1.0 + +Status: Companion specification draft +Date: 2026-08-11 + +--- + +## 1. Scope + +This companion specification defines how an authorization server discovers, +retrieves, validates, and accepts a source declaration. The Core specification +defines the `SourceDeclaration`, selection request, grant, and resource server +semantics. This specification does not redefine those contracts. + +Discovery is an onboarding concern. It is not a resource server authorization +dependency. A resource server enforces a resolved grant without retrieving a +current declaration. + +The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, +RECOMMENDED, NOT RECOMMENDED, MAY, and OPTIONAL in this document are to be +interpreted as described in BCP 14 [RFC 2119] [RFC 8174] when, and only when, +they appear in all capitals. + +## 2. Provider-native discovery + +An authorization server that onboards a provider-native source SHALL start +with an already accepted protected-resource identifier. The identifier MUST be +an HTTPS URI without a fragment or user information. It SHOULD NOT contain a +query component. + +The authorization server SHALL derive the protected-resource metadata URL as +specified by RFC 9728 Section 3.1. It inserts +`/.well-known/oauth-protected-resource` between the authority and any path or +query component. It removes the terminating slash after the authority before +insertion. For example: + +| Protected-resource identifier | Metadata URL | +| --- | --- | +| `https://resource.example.com` | `https://resource.example.com/.well-known/oauth-protected-resource` | +| `https://resource.example.com/` | `https://resource.example.com/.well-known/oauth-protected-resource` | +| `https://resource.example.com/?tenant=one` | `https://resource.example.com/.well-known/oauth-protected-resource?tenant=one` | +| `https://resource.example.com/owner/alice` | `https://resource.example.com/.well-known/oauth-protected-resource/owner/alice` | + +The authorization server SHALL retrieve the metadata with HTTP `GET`. The +returned `resource` value MUST be byte-for-byte identical to the protected- +resource identifier used for the request. + +PDPP defines the protected-resource metadata member +`pdpp_source_declaration_uri`. It contains one HTTPS URI string without a +fragment or user information. The member is OPTIONAL in generic protected- +resource metadata. It is REQUIRED when the resource is onboarded as one +provider-native PDPP source. + +The retrieved `SourceDeclaration.source.kind` MUST be `provider_native`, and +`SourceDeclaration.source.id` MUST be identical to the accepted +protected-resource identifier. The authorization server SHALL reject either +mismatch before consent or grant issuance. + +## 3. Source onboarding and authority + +An ordinary authorization request SHALL name only a source already accepted by +the authorization server. A new provider-native resource SHALL enter through +explicit owner or operator onboarding. A client SHALL NOT select a new +resource authority or declaration URI during authorization. + +Connector and community sources SHALL enter through an installed catalog, an +accepted registry entry, or explicit local provisioning. Local provisioning +MAY allow private or local endpoints under the operator's network policy. This +local exception does not change the public protocol requirements. + +TLS authentication of protected-resource metadata authenticates the resource +authority and its declaration pointer. The declaration MAY be hosted on a +different origin. The declaration host does not, by itself, authenticate +`publisher.id`. + +The authorization server SHALL keep resource authority separate from publisher +attribution. It SHALL treat `publisher.id` as authenticated only when an +accepted channel or configured mapping binds that publisher to the declaration. +Without that binding, the publisher value is a non-authoritative claim and +MUST NOT support source acceptance, redirect policy, attribution, or another +trust decision. + +## 4. Bounded declaration retrieval + +The declaration retriever SHALL: + +1. Use HTTPS without ambient credentials. +2. Enforce configured response-byte, time, and retrieval-depth limits. +3. Require every redirect target and the final declaration URL to satisfy the + accepted declaration pointer and the configured redirect policy. The policy + MAY reject all redirects. +4. Resolve DNS freshly for every connection attempt, including each redirect + hop. +5. Validate every resolved address against the applicable network policy before + connecting. +6. Connect only to an address from that validated result while preserving the + destination authority for TLS authentication. +7. Reject a declaration that requires automatic retrieval of a remote schema. +8. Fail closed when a bound, validation, redirect, network, or identity check + fails. + +An address accepted for an earlier connection attempt MUST NOT authorize a +later DNS result. Validation of the final declaration URL is separate from +validation of `SourceDeclaration.source.id`. The declaration location is not +the source identity. + +## 5. Accepted revisions + +An accepted revision SHALL be keyed by its accepted authority binding, +`source.id`, and opaque `declaration_version`. After JSON parsing and Source +Declaration validation, later content under the same key MUST compare equal as +parsed JSON. + +An implementation MAY use an internal content fingerprint to accelerate this +comparison. The fingerprint algorithm is not a protocol identity and need not +be portable between implementations. + +When the authorization server uses provider-native discovery for consent, its +consent and audit evidence SHALL retain an unambiguous AS-local +accepted-revision reference to the accepted authority binding and parsed +revision retained by this AS. That reference is not a portable authorization +right, grant identity, bearer handle, or cross-AS declaration credential. + +Different parsed content under an accepted key is equivocation. The +authorization server SHALL reject it and retain the previously accepted +content. It SHALL NOT infer ordering or freshness from +`declaration_version`. A pointer to a previously accepted revision is accepted +or rejected only under explicit publisher or local rollback policy. + +## 6. Use and lifecycle + +Declaration display values are untrusted input. An implementation SHALL escape +them for their output context and enforce configured response, parser, display, +and logging limits before consent rendering or logging. + +Current declaration query capabilities MUST NOT widen an issued grant. A local +block MAY prevent a declaration from being used for new consent. That block +MUST NOT automatically revoke historical grants. + +The Collection Profile remains OPTIONAL. Discovery and trust apply equally to +provider-native sources, pre-collected sources, and connector-backed sources. +An accepted source does not need a Collection Profile extension unless the +implementation uses Collection Profile behavior for that source. + +## References + +- RFC 2119, Key words for use in RFCs to Indicate Requirement Levels +- RFC 8174, Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words +- RFC 9728, OAuth 2.0 Protected Resource Metadata +- [PDPP Core](spec-core) +- [PDPP Collection Profile](spec-collection-profile) diff --git a/test-accounting.manifest.json b/test-accounting.manifest.json index 76754146c..a19c1510c 100644 --- a/test-accounting.manifest.json +++ b/test-accounting.manifest.json @@ -46,6 +46,7 @@ "reference-implementation/test/*.test.mjs", "reference-implementation/test/*.test.ts", "reference-implementation/runtime/connector-child-environment.test.ts", + "reference-implementation/test/seam-spike/*.test.ts", "reference-implementation/server/streaming/*.test.js", "reference-implementation/server/streaming/*.test.mjs", "reference-implementation/server/streaming/*.test.ts",