Skip to content

Re-vendor the gateway schema and validator bundle: OAuth scopes are optional - #42

Draft
oschaaf wants to merge 2 commits into
mainfrom
fix/2046-optional-oauth-scopes
Draft

Re-vendor the gateway schema and validator bundle: OAuth scopes are optional#42
oschaaf wants to merge 2 commits into
mainfrom
fix/2046-optional-oauth-scopes

Conversation

@oschaaf

@oschaaf oschaaf commented Sep 6, 2026

Copy link
Copy Markdown
Member

Hold: do not merge until the product schema change (d2#2051) is deployed AND gateways run ContextForge 1.0.7.1 or later. The schema deploy covers the static-credential OAuth shape; the issuer-only (DCR) shape additionally needs 1.0.7.1, because a scopeless config emits an explicit empty list and gateways before 1.0.7.1 then post "scope": "" in the RFC 7591 registration request. Until both hold, this PR would teach the skill and the harness to produce configs the live product rejects or mis-registers.

Why

The gateway config schema is making mcp_servers[].authentication (oauth).scopes optional with an explicit empty-list default. Some OAuth providers (Docebo is the public example) reject any scope parameter with invalid_scope; with no scopes configured the gateway sends no scope parameter at all. This repo still says scopes are mandatory in three places, and both vendored artifacts predate the change.

What changes

  • Vendored schema artifact re-copied from the product generator; digest regenerated. The scopes-related churn: the scopes row (optional, default [], new guidance text), the gateway.authentication.enabled row's Required flag (defaulted fields now report no), and the oauth variant's required-field list drops scopes. The previous vendor dated from 2026-08-20, so the copy also picks up everything the product merged since (OAuth token_endpoint_auth_method and omit_resource, session-control redirect_uri and optional client_id, the CGNAT allowed-networks guidance, six more reserved keys). Verbatim copies cannot be split, so this is one commit that can be dropped whole.
  • Digest generator grew a Default column and now renders every OAuth field the artifact carries (the hardcoded nine-field list left the two new fields out and the generator's own coverage gate refused the new artifact).
  • Hand-coded prose in the digest generator and in the per-variant table of dtwo-gateway-config/SKILL.md no longer claims scopes is required; it now says to omit it (or set []) for providers that reject any scope.
  • Validator bundle re-vendored to the same product revision; version and sha pins updated. The bundle now accepts a scopeless OAuth server and still rejects an empty scope string.
  • New fixture for a provider that rejects scopes, plus a max_length constraint kind (an absent path counts as length 0) so a fixture can assert "no scopes or an empty list".
  • Harness bug fix, separate commit: the fixture-schema union listed equals: z.unknown() first, so every regex and min_length constraint parsed to a bare {path} and never fired. Fixed with strict objects and specific kinds first; recorded in known-defects. Expect some aspirational pass rates to drop on the next live run, since 49 assertions start counting.
  • plugin.json version bump (SKILL.md content ships to customers).

Follow-up (product side, not this PR)

The vendored bundle inlines two product-template comment lines that name an internal design record. They were present in the previous bundle too; the product's bundle build should strip comments or extend its leak guard.

Gates

  • skill-harness: pnpm test 186 passed / 0 failed (31 suites, including the digest drift check and both artifact sha pins); pnpm biome:check clean.
  • Root: node scripts/generate-schema-digest.mjs --check in sync.
  • Re-vendor fidelity: the schema artifact is byte-identical to the product generator's output, and the validator bundle was rebuilt independently from the same product revision and is byte-identical; version and sha pins agree with the product's committed sidecar.
  • Bundle probe through the harness seam: omitted, [], and a bare scopes: all parse to []; scopes: [""] is rejected.

@oschaaf
oschaaf requested a review from pareilly September 6, 2026 06:39
@pareilly

pareilly commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Nice piece of work — especially finding and fixing the constraint bug while adding the new kind. Reviewed alongside the product-side schema change and the gateway-side DCR change.

I verified the re-vendor claim independently rather than trusting the description: shasum -a 256 of the vendored bundle on this branch matches the sha the product repo writes to its own sidecar exactly, and pnpm test in skill-harness gives 186 passed / 0 failed on the PR head. The digest's embedded artifact sha lines up too.

I also probed ValueConstraintSchema directly, since the bug you fixed was a silent one:

{path}                          → rejected  (the refine fires)
{path, regexp: '^x'}            → rejected  (strict catches the misspelling)
{path, regex: '^x', min_length} → rejected  (two kinds)
{path, regex} / {min_length} / {max_length} / {equals}  → correct kind each time

So the union reorder plus strictObject does what the comment says it does. The known-defects.md entry is honest about the pass-rate consequence, which is the right call.

One thing worth checking before the hold lifts, and one carry-over.


The hold should name the gateway version, not just the schema deploy

Hold: do not merge until the product schema change is deployed.

That covers the static-credential OAuth shape, but not the DCR / issuer-only one. Once the schema change is live, an OAuth server authored without scopes emits an explicit empty list, and on the gateway side oauth_config.get("scopes", settings.dcr_default_scopes) returns that empty list rather than falling back to the DCR default. Gateways before 1.0.7.1 then send an empty scope string in the RFC 7591 registration request — which is exactly what the gateway-side fix in 1.0.7.1 addresses.

This matters here because the skill and the harness both learn from this change that a scopeless OAuth block is fine, and the skill can produce an issuer-only block just as easily as a static-credential one. docebo-oauth-no-scopes.yaml is safely the static-credential shape (forbidden_paths includes issuer, good), so the fixtures aren't affected — but the guidance the skill ships is.

Two options: extend the hold to "schema change deployed and gateways on ≥1.0.7.1", or add a clause to the scopes guidance noting that on the DCR path an omitted scopes registers the client with no scopes at all, and that the gateway's own DCR default no longer applies.

That second point is worth making regardless of timing — the product now always emits the key, so the DCR default becomes unreachable for these configs. The current guidance only covers the "provider rejects any scope" case, which is a different situation from "I'm using DCR and didn't think about scopes."

Carry-over: the vendored bundle still inlines the two product-template comment lines

You flagged this in the description as a product-side follow-up, and it is pre-existing rather than introduced here — but this commit does re-publish it, so it's worth opening the follow-up now rather than leaving it in a PR body. Stripping comments in the bundle build (or extending the leak guard to cover them) would close it at the source, and the same guard would catch the next one.


Coverage

The harness tests are the strongest part of this. max_length gets four cases including the absent-path and non-array-target branches, the regex regression gets a battery-level assertion rather than a single unit, and the unparseable-pattern path is scored instead of thrown. I didn't find a gap worth naming.

The digest generator's shift from a hardcoded field list to "preferred order, then everything else in artifact order" is the right fix for the coverage gate — I checked the rendered OAuth table and it picked up only the two genuinely user-facing fields, with none of the lower-level OAuth knobs leaking into the customer-facing digest.

…ptional

`mcp_servers[].authentication.scopes` (type: oauth) no longer has to carry
an entry. Some OAuth providers reject ANY `scope` parameter with
`invalid_scope` (Docebo is the public example); with `scopes` omitted or
`[]` the gateway now sends no `scope` parameter at all. The artifact
reports every field with a schema default as `required: false`, which
also flips `gateway.authentication.enabled` (default `true`) to optional
and drops `scopes` from the oauth variant's requiredFields.

Vendored from product-repo revision 9e37ef5c (the change that made OAuth
scopes optional), both files byte-copied together:

- dtwo/skills/dtwo-gateway-config/schema-reference.json — generatorVersion
  1.1.0, sha256 22f5fa77bd79b0686b23f663756e787613547f2f2d65e8e98d2a5d012ec0e30e
- skill-harness/vendor/config-validator.bundle.mjs — bundle 4.0.0, sha256
  d286b15fd04cb5a782fad26ed797a6347c0fbb00eef987355812853ba44488a0,
  verified against that revision's committed validator-bundle.sha256
  sidecar before copying. The .d.mts shim is byte-identical to the old one.

The previous vendor (2026-08-20) lagged the product repo, so this refresh
also carries what landed upstream since, none of it scopes-related:
`token_endpoint_auth_method` and `omit_resource` on the oauth variant
(the bundle rejects an unimplemented auth method and `client_secret_basic`
without a `client_secret`), `session_control.redirect_uri` plus an
optional `session_control.client_id` with two new cross-field
constraints, refreshed clearing/CGNAT guidance, and six more reserved
`gateway.advanced` keys.

Digest generator: the OAuth variant table was a hardcoded field list with
no Default column, so the coverage gate refused the new artifact (the two
new oauth fields rendered nowhere; `scopes`' `[]` default had no cell).
The table is now ordered-then-data-driven and routes its Default column
through defaultCell; the hand-written "both shapes still require ...
scopes" sentence — and the per-variant table outside the generated block
in SKILL.md — now say scopes is optional and when to leave it out, with
one clause on the DCR path: an issuer-only block with `scopes` omitted
registers the client with no scopes at all; the gateway's own DCR default
does not apply to configs authored here. The bundle also rejects a
singular `scope` key on an oauth block ("did you mean scopes") instead of
stripping it silently.

Pins: EXPECTED_SCHEMA_ARTIFACT_SHA256, EXPECTED_VALIDATOR_BUNDLE_VERSION
(2.0.0 -> 4.0.0) and EXPECTED_VALIDATOR_BUNDLE_SHA256 updated together.
The three GATEWAY_OWNED_SAFE_DEFAULTS rationales are unchanged in the
refreshed artifact (re-verified per README).

known-defects.md: the re-vendor log sits on the resolved entry 4; the
stale duplicate open copy of that entry is removed.

Plugin version 1.1.7 -> 1.1.8: SKILL.md content ships to customers.

Hold: merge only after the product schema change is deployed AND
gateways run 1.0.7.1 or later. Until the schema deploys the harness would
accept scopeless OAuth configs the live product still rejects; and on the
issuer-only/DCR shape the explicit empty scopes list the product now
always emits makes pre-1.0.7.1 gateways send an empty `scope` string in
the RFC 7591 registration request instead of falling back to their DCR
default.

Signed-off-by: Otto van der Schaaf <otto@dtwo.ai>
…add a no-scopes OAuth fixture

Adding a `max_length` value-constraint kind exposed that `regex` and
`min_length` had never been enforced. `ValueConstraintSchema` listed the
equals-constraint (`equals: z.unknown()`) first in the zod union;
`z.unknown()` accepts an absent key and a plain object strips unknown
keys, so `{ path, regex }` and `{ path, min_length }` both parsed to a
bare `{ path }`, `constraintKind` fell through to min_length with an
undefined bound, and `len < undefined` never fails. 37 regex and 12
min_length assertions across the battery were vacuous; `equals`,
`required_paths` and `forbidden_paths` were unaffected. Recorded as
known-defects.md entry 5 (`value-constraints-stripped-at-parse`), with
the caveat that past bench passes on those assertions are unproven.

Fix: specific kinds match first, every member is a strictObject (a
misspelt key is now a fixture-load error), the equals-constraint refines
on key presence, and fixtures.test.ts asserts every constraint in the
battery keeps exactly one kind key after parse (>= 100, anti-vacuity).

`max_length` mirrors `min_length` except that an absent path counts as
length 0, so `max_length: 0` reads "absent or empty". `forbidden_paths`
cannot express that for `scopes`: on a validating config parseConfig
fills the defaulted `scopes: []` in, so the key is present whether or
not the YAML carried it.

New aspirational fixture `docebo-oauth-no-scopes`: authorization_code
with a static client triple, authorize/token on the generic
`/oauth2/authorize` + `/oauth2/token` shapes at placeholder domains, and
`scopes` asserted absent-or-empty because the provider rejects any
`scope` parameter. Its must_validate assertion needs the 3.0.0 validator
bundle from the previous commit.

Battery: 12 required + 18 aspirational; README tables and counts updated.
Signed-off-by: Otto van der Schaaf <otto@dtwo.ai>

@pareilly pareilly left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These and my changes will have conflicts because they both are taking changes from the same sources. We will probably want mine to go in first and then rebase yours.

However, they both have to wait until after we deploy to prod on Thursday

@oschaaf

oschaaf commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Thanks — both points taken, and the probe you ran is exactly the assurance I wanted on the union fix.

  • Hold names the gateway version: the PR body and the re-vendor commit body now read "merge only after the product schema change is deployed AND gateways run 1.0.7.1 or later", with the RFC 7591 empty-scope reason spelled out.
  • DCR clause in the shipped guidance: done regardless of timing. The product-side scopes rationale gained a sentence (on the DCR path an omitted scopes registers the client with no scopes; the gateway's own DCR default does not apply to configs authored here), so the digest carries it, and the hand-written oauth row plus the generator's sentence say the same.
  • Re-vendored again from the amended product revision: schema artifact and bundle byte-identical to the product files, bundle now 4.0.0 (the product added a did you mean scopes rejection for a singular scope: key, which is a new must_validate rejection, hence the major bump), pins updated. Probe: omitted / [] / bare scopes: accept; scopes: [""] and singular scope: "x" reject.
  • Carry-over (ADR comment lines in the bundle): filed on the product side as dtwoai/d2#2052 with the source lines and the fix (strip template comments in the bundle build, extend the leak guard).

Rebuilt commits: 1dab19c (re-vendor, held) + c57ad74 (harness). 186/186 tests, digest in sync.

@oschaaf
oschaaf force-pushed the fix/2046-optional-oauth-scopes branch from 30af3f6 to c57ad74 Compare September 6, 2026 15:28
@pareilly

pareilly commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Re-reviewed at c57ad741. The re-vendor lands cleanly and the widened hold covers what I raised — thanks for turning that around so fast.

Verified against the product head, not the description

All four pins agree exactly, and the vendored bytes hash to their own pins:

this branch product
bundle version 4.0.0 4.0.0
bundle sha d286b15f… d286b15f… (committed sidecar)
artifact sha 22f5fa77… 22f5fa77…

pnpm test in skill-harness: 186 passed / 0 failed.

I also probed the newly vendored bundle through its own seam rather than trusting the log line:

vendored bundle version: 4.0.0
  no scopes key      → OK scopes=[]
  scopes: []         → OK scopes=[]
  bare scopes:       → OK scopes=[]
  scopes: [""]       → REJECTED
  singular scope:    → REJECTED  ("did you mean scopes")
  capitalised Scope: → REJECTED  ("did you mean scopes")

So the typo guard is live at the harness seam, and the DCR clause propagated to all three places in the skill — the generated scopes row, the hand-written cross-field prose, and the auth-variant summary table. Nothing further from me on the scopes work itself.


Still open: the collision with #40

The update didn't touch this, and it's the one thing I'd want settled before either merges. #40 branches from the same commit as this one and neither contains the other, so whichever lands second inherits the conflict.

Both still bump plugin.json to 1.1.8. Second to land needs 1.1.9.

Four files conflict on a test-merge. Three resolve mechanically to this branch's side, because its vendored artifacts strictly supersede #40's — I diffed them, and every session-control / redirect_uri / reserved-key change #40 vendored is already byte-present here:

  • dtwo/skills/dtwo-gateway-config/SKILL.md — take this branch
  • skill-harness/src/schemaArtifact.ts, skill-harness/src/validatorBundle.ts — take this branch

The fourth needs a decision, not a resolution. renderOAuthVariant in scripts/generate-schema-digest.mjs diverges on design:

  • Document the human-approved clear path in the policy skills #40 added token_endpoint_auth_method / omit_resource to the ordered allowlist, deliberately keeping it a hard gate — "a field newly added to the artifact is DROPPED here rather than appended blindly ... safe only because the coverage check refuses to write the digest until the field is placed deliberately."
  • This branch replaces the allowlist with preferred-order-then-auto-append. Its comment ("this list used to BE the table, so when the artifact grew … they rendered nowhere") describes main, not the post-Document the human-approved clear path in the policy skills #40 state where that was already fixed.

The consequence is real: with auto-append, the "every user-audience field with a target must render" clause of assertDigestCoverage can no longer fire for the OAuth section. That's a defensible trade — never silently drops a field, in exchange for never forcing a deliberate placement — but worth choosing rather than letting conflict resolution choose.

One more sequencing note

This branch's hold now names the schema deploy and 1.0.7.1. But the re-vendored digest also carries the rewritten clearing.enabled guidance and the optional-client_id / redirect_uri rows — which is exactly what #40's hold says is gated on a different release. So merging this one first would ship #40's gated content ahead of its gate, even though nothing in this branch's own hold mentions it.

Landing #40 first and rebasing this onto it keeps that honest, and the rebase is mostly "take ours" per the table above. The reverse order works too, but means deleting most of #40 and bypassing its gate.

Carry-over

The two product-template comment lines in the vendored bundle are unchanged — still worth opening that product-side follow-up (strip comments in the bundle build, or extend the leak guard) rather than leaving it in a PR description, since each re-vendor re-publishes them.

@oschaaf

oschaaf commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Agreed on all counts, and thanks for the test-merge against #40 — that saved the guesswork.

Order: #40 first, then this branch rebases onto it and bumps plugin.json to 1.1.9. Both wait for the Thursday prod deploy; nothing merges before that. I'll do the rebase once #40 is in rather than pre-resolving against a moving target.

The renderOAuthVariant decision: I'll take #40's design. The hard allowlist plus the coverage gate is the mechanism this repo chose so a new artifact field gets placed deliberately instead of appended into a customer-facing table, and my auto-append version was written against main, where the allowlist was simply stale — #40 had already fixed that. At rebase time I'll keep #40's ordered allowlist, add scopes (and the Default column, which is the one part of my change worth keeping, since scopes now has a schema default of []), and delete my "this list used to BE the table" comment, which describes a state that no longer exists.

Sequencing note: understood — merging this first would ship #40's gated clearing.enabled / optional-client_id / redirect_uri content ahead of its gate. Landing #40 first closes that.

Heads-up on the pins: dtwoai/d2#2051 is getting one more amend from its own review (the scope-typo guard narrows to fire only when scopes is absent or empty, so it can never newly reject a stored config). That changes the bundle bytes, so this branch's pins will lag the product head until the rebase; I'll re-vendor from the final product commit as part of the rebase onto #40 so it is done once.

Carry-over: filed as dtwoai/d2#2052 (strip template comments in the bundle build, extend the leak guard).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants