diff --git a/dtwo/.claude-plugin/plugin.json b/dtwo/.claude-plugin/plugin.json index 5663008..680980f 100644 --- a/dtwo/.claude-plugin/plugin.json +++ b/dtwo/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.2.2", + "version": "1.3.0", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/.codex-plugin/plugin.json b/dtwo/.codex-plugin/plugin.json index 6d382dd..75b1d66 100644 --- a/dtwo/.codex-plugin/plugin.json +++ b/dtwo/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.2.2", + "version": "1.3.0", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/.cursor-plugin/plugin.json b/dtwo/.cursor-plugin/plugin.json index 206a2b0..bc44a39 100644 --- a/dtwo/.cursor-plugin/plugin.json +++ b/dtwo/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.2.2", + "version": "1.3.0", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/skills/dtwo-gateway-config/SKILL.md b/dtwo/skills/dtwo-gateway-config/SKILL.md index 1427a8b..d2be53e 100644 --- a/dtwo/skills/dtwo-gateway-config/SKILL.md +++ b/dtwo/skills/dtwo-gateway-config/SKILL.md @@ -43,6 +43,7 @@ The tools listed below reflect the initial set. The Dtwo MCP server may add new - Do not guess gateway UIDs when they can be discovered with `dtwo-list-gateways`. - Validate draft YAML before saving or publishing. - Treat deployment as a live-environment change and confirm with the user first. +- Never write a credential into gateway YAML, and never ask for one in the chat. Reference a Vault entry instead and have the user enter the value in the Dtwo Hub (see Secrets and the Vault). - Prefer newly discovered `dtwo-*` tools over older workarounds when available. ## Communicating with the User @@ -66,8 +67,8 @@ The steps above (tool names, YAML field names, the validate → save → publish | `dtwo-update-gateway` | Update gateway metadata (name, tags) | | `dtwo-get-gateway-config` | Fetch the draft YAML configuration (includes MCP server definitions) | | `dtwo-get-gateway-versions` | List published versions for a gateway | -| `dtwo-validate-gateway-config` | Validate YAML configuration without saving | -| `dtwo-save-gateway-draft-config` | Validate and save YAML as the draft configuration | +| `dtwo-validate-gateway-config` | Validate YAML without saving, using the same check as a deploy. Resolves Vault references (pass `gatewayUid` so the gateway's own entries resolve) and lists the entries the config uses | +| `dtwo-save-gateway-draft-config` | Save YAML as the draft configuration. It does **not** validate, so validate first | | `dtwo-publish-gateway-config` | Publish the gateway draft as a new version | | `dtwo-revert-gateway-config` | Restore a published `version` back into the draft. Pass `publish: true` to publish it immediately as well | @@ -79,6 +80,19 @@ The steps above (tool names, YAML field names, the validate → save → publish | `dtwo-get-gateway-deployments` | List deployment tasks for a gateway | | `dtwo-get-deployment` | Check status of a specific deployment | +### Vault Tools + +The Vault holds values that gateway configs reference by name (see Secrets and the Vault). No tool takes or returns a secret's value. + +| Tool | Purpose | +|------|---------| +| `dtwo-list-vault-entries` | List Vault entries (filter by `name`, or by `gatewayUid` for one gateway's entries). Check here before adding an entry, to reuse one that exists | +| `dtwo-get-vault-entry` | Fetch one entry by UID. An encrypted entry's value is always `null` | +| `dtwo-get-vault-secret-link` | Get a Hub link where the user types a secret in themselves. Pass `name` (and optionally `type`, `description`, `gatewayUid`) for a new secret, or only `uid` to replace an existing one. Creates and changes nothing until the user saves the form | +| `dtwo-create-vault-entry` | Add a **plain** (unencrypted) entry. Only for values that aren't secret, such as an upstream URL or a timeout; anyone who can see the Vault can read it | +| `dtwo-update-vault-entry` | Change a plain entry's value, or any entry's description. Refuses a value for an encrypted entry | +| `dtwo-delete-vault-entry` | Delete an entry, including an encrypted entry's secret. A config that still references it without a fallback fails validation and its next deploy, so confirm with the user first | + ### Deletion (not supported via MCP) The Dtwo MCP server does not expose a `delete-gateway` tool. `revert-gateway-config` restores a prior version — it does **not** delete. @@ -118,7 +132,7 @@ This subsection is generated from `schema-reference.json` by `scripts/generate-s | `gateway.authentication.oauth_dcr` | OAuth Dynamic Client Registration overrides. Defaults are auto-derived from mcp_servers; set fields here to override (typically to disable DCR/discovery for IdPs that pre-provision clients). | | `gateway.ssrf` | SSRF protection overrides. Strict defaults apply when omitted. | | `gateway.intent` | Gateway session-intent controls. | -| `gateway.session_control` | Human-gated session clearing (session-control) registration: the IdP app the browser ceremony authenticates against. Arms the platform clear tools and the browser clear ceremony when `intent.enabled: true`, or when `clearing.enabled: true` on a gateway that uses markers without intent capture. A gateway using Dtwo authentication needs no credentials here — it gets its organization's platform-provisioned app — but prefer `clearing: {enabled: true}` over an empty block, so the block states what it does: an empty one arms only while `intent.enabled` is true and parks inert otherwise. `client_id` is only needed for a gateway trusting a customer-run IdP, and `redirect_uri` only for one the browser reaches at a different origin than its token audience. The ceremony issuer is not configured here — it is always `gateway.authentication.jwks_info.jwt_issuer` (the same tenant issues both the inbound tokens and the browser-login ID tokens), and the deploy derives `SESSION_CONTROL_ISSUER` from it. | +| `gateway.session_control` | Human-gated session clearing (session-control) registration: the IdP app the browser ceremony authenticates against. Arms the platform clear tools and the browser clear ceremony when `intent.enabled: true`, or when `clearing.enabled: true` on a gateway that uses markers without intent capture. A gateway using Dtwo authentication needs no credentials here — it gets its organization's platform-provisioned app — but prefer `clearing: {enabled: true}` over an empty block, so the block states what it does: an empty one arms only while `intent.enabled` is true and parks inert otherwise. `client_id` is only needed for a gateway trusting a customer-run IdP, and `redirect_uri` only for one the browser reaches at a different origin than its token audience, or whose first `jwt_audience` is not an absolute URL (where it is required). The ceremony issuer is not configured here — it is always `gateway.authentication.jwks_info.jwt_issuer` (the same tenant issues both the inbound tokens and the browser-login ID tokens), and the deploy derives `SESSION_CONTROL_ISSUER` from it. | | `gateway.session_control.clearing` | Arming control for human-gated clearing. | | `mcp_servers[]` | One entry per upstream MCP server. `name` and `url` required. | | `mcp_servers[].authentication` | Discriminated union keyed on `type`; outbound auth from gateway to the upstream server. 7 variants (see table). | @@ -199,7 +213,7 @@ Strict defaults block localhost, private networks, and fail-closed DNS when omit #### `gateway.advanced` and `gateway.log_level` - **`advanced`** — `array`, `targetKind: advanced`. Lines are appended verbatim to the deployed env file under systemd `EnvironmentFile` semantics (last-occurrence-wins). Validation rejects two classes of keys: keys already emitted by a typed field (so it cannot shadow one by accident) AND every name on the reserved-keys list below. Keys here are case-sensitive — preserve exact casing. -- **`log_level`** — enum: `TRACE`/`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`. Default: `"DEBUG"` (deploy). Target: `LOG_LEVEL`. +- **`log_level`** — enum: `TRACE`/`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`. Default: `"INFO"` (deploy). Target: `LOG_LEVEL`. #### Reserved keys — rejected inside `gateway.advanced` @@ -265,12 +279,12 @@ Gateway session-intent controls. #### `gateway.session_control` — human-gated clearing registration -Human-gated session clearing (session-control) registration: the IdP app the browser ceremony authenticates against. Arms the platform clear tools and the browser clear ceremony when `intent.enabled: true`, or when `clearing.enabled: true` on a gateway that uses markers without intent capture. A gateway using Dtwo authentication needs no credentials here — it gets its organization's platform-provisioned app — but prefer `clearing: {enabled: true}` over an empty block, so the block states what it does: an empty one arms only while `intent.enabled` is true and parks inert otherwise. `client_id` is only needed for a gateway trusting a customer-run IdP, and `redirect_uri` only for one the browser reaches at a different origin than its token audience. The ceremony issuer is not configured here — it is always `gateway.authentication.jwks_info.jwt_issuer` (the same tenant issues both the inbound tokens and the browser-login ID tokens), and the deploy derives `SESSION_CONTROL_ISSUER` from it. +Human-gated session clearing (session-control) registration: the IdP app the browser ceremony authenticates against. Arms the platform clear tools and the browser clear ceremony when `intent.enabled: true`, or when `clearing.enabled: true` on a gateway that uses markers without intent capture. A gateway using Dtwo authentication needs no credentials here — it gets its organization's platform-provisioned app — but prefer `clearing: {enabled: true}` over an empty block, so the block states what it does: an empty one arms only while `intent.enabled` is true and parks inert otherwise. `client_id` is only needed for a gateway trusting a customer-run IdP, and `redirect_uri` only for one the browser reaches at a different origin than its token audience, or whose first `jwt_audience` is not an absolute URL (where it is required). The ceremony issuer is not configured here — it is always `gateway.authentication.jwks_info.jwt_issuer` (the same tenant issues both the inbound tokens and the browser-login ID tokens), and the deploy derives `SESSION_CONTROL_ISSUER` from it. | Field | Required | Type | Default | Target | Guidance (from artifact) | |---|---|---|---|---|---| | `client_id` | no | string | `not declared` | `SESSION_CONTROL_CLIENT_ID` | Leave unset on a gateway using Dtwo authentication. Set it only when the gateway trusts your own IdP: the ceremony app must live in the IdP that issues the gateway's inbound tokens, so register a dedicated public client there (authorization-code + PKCE, no refresh grant) and paste its client id here. Do not reuse the gateway API client. | -| `redirect_uri` | no | string | `not declared` | `SESSION_CONTROL_REDIRECT_URI` | Leave unset in almost all cases — the gateway derives `/session-control/clear/callback` and the platform registers that. Set it when a reverse proxy or split-horizon DNS puts the browser on a different origin than the audience: give the URL the browser can actually reach, at the root path (`/mcp/*` routes to the streamable-HTTP handler, which rejects browser GETs). | +| `redirect_uri` | no | string | `not declared` | `SESSION_CONTROL_REDIRECT_URI` | Leave unset in almost all cases — the gateway derives `/session-control/clear/callback` and the platform registers that. Set it when a reverse proxy or split-horizon DNS puts the browser on a different origin than the audience: give the URL the browser can actually reach, at the root path (`/mcp/*` routes to the streamable-HTTP handler, which rejects browser GETs). It is required when the first `jwt_audience` is not an absolute URL — e.g. an Entra ID v2.0 gateway, whose audience is the gateway app's client ID (a GUID) — since there is no origin to derive from, and the deploy rejects an armed gateway that leaves it unset. | **Cross-field constraints** (verbatim from artifact): > `clearing.enabled` unset follows `gateway.intent.enabled`; an explicit `true` arms clearing even with intent capture off (the markers-only deployment). @@ -284,6 +298,8 @@ Human-gated session clearing (session-control) registration: the IdP app the bro > `redirect_uri`, when set, replaces the callback the gateway would derive from the first entry of `jwt_audience`, and is the URL the platform allow-lists on the IdP application. Unset, ordering within a comma-separated `jwt_audience` decides the callback. > > While clearing is armed, `jwt_issuer` must be a normalized HTTPS URL (it becomes the ceremony issuer via `SESSION_CONTROL_ISSUER`) and `jwt_audience` must not be blank. +> +> While clearing is armed, `redirect_uri` is required when the first entry of `jwt_audience` is not an absolute `http(s)://` URL — for example an Entra ID v2.0 client ID or an `api://` Application ID URI: there is no browser-reachable origin to derive the callback from. #### `gateway.session_control.clearing` — arming control @@ -363,9 +379,9 @@ Required-ness here is *within the variant*: the field must appear when that `typ | `query_param` | `param_value` | yes | string | `sotw.auth_query_param_value` | | `cert` | `ca_cert` | yes | string | `sotw.ca_certificate` | -#### Secret-typed fields — must emit a placeholder, never a literal +#### Secret-typed fields: must hold a Vault reference, never a literal -Every field marked `secret: true` in the artifact. Emit a self-describing placeholder (`REPLACE_WITH_`, `PLACEHOLDER_`, `YOUR_`, `CHANGE_ME`, or `${ENV_VAR}`); never an inferred or literal credential. Bare `PLACEHOLDER` does not count. +Every field marked `secret: true` in the artifact. Put a Vault reference there (`<>`, with no `|` fallback), never an inferred or literal credential. See Secrets and the Vault for how the user adds the value. | Path | |---| @@ -382,7 +398,7 @@ Every field marked `secret: true` in the artifact. Emit a self-describing placeh - **`targetKind: platform`** — value is applied as a platform-side control at the named `platform.*` path rather than written to the gateway env file. - **`targetKind: sotwPath`** — value is written into the SOTW YAML at the named dotted path (e.g. `sotw.url`, `sotw.oauth_config.client_secret`). Read the `Target` column per field; do not infer a field's target from its section. - + @@ -412,7 +428,42 @@ Supported authentication types: | `oauth` | `grant_type`, `scopes`, plus either `issuer` (for DCR) or `client_id` + `client_secret` + `token_url`. Optional: `authorization_url`, `redirect_uri`, `pkce_enabled`, `extra_authorize_params`, `scope_param_name`, `scope_separator`, `token_response_path`, `token_lifetime_seconds`, `oauth_quirks` | | `cert` | `ca_cert` (PEM) | -**Secret-typed fields** (any field with `secret: true` in the schema — `token`, `password`, `client_secret`, `authheaders.headers[].value`, `query_param.param_value`) must never carry inferred or literal credentials. Emit a self-describing placeholder in one of these shapes: `REPLACE_WITH_`, `PLACEHOLDER_`, `YOUR_` / `your-` (matching the OAuth example below), `CHANGE_ME`, or `${ENV_VAR}`. Bare `PLACEHOLDER` (no suffix), `FILL_FROM_ENV`, and descriptive prose like `placeholder-replace-me` do **not** count — the placeholder must be self-describing so the operator can see which value to substitute. +**Secret-typed fields** (any field with `secret: true` in the schema: `token`, `password`, `client_secret`, `authheaders.headers[].value`, `query_param.param_value`) must never carry inferred or literal credentials. Put a Vault reference there instead, as described next. + +### Secrets and the Vault + +The Vault stores values that gateway configs use by name. A reference is the entry's name between `<<` and `>>`, and the gateway receives the stored value when it deploys: + +```yaml +authentication: + type: bearer + token: <> +``` + +- **Names** use letters, digits, and underscores, and start with a letter or underscore. Pick a name that says what the value is, such as `GITHUB_TOKEN` or `SLACK_CLIENT_SECRET`. +- **Make the reference the whole value of a secret field.** For a header that needs a scheme, such as `Authorization: Bearer …`, the user stores the full header value (scheme included) in the entry, and the field holds only `<>`. Say so when you give them the link. +- **A reference can carry a fallback**, `<>`, used when no entry by that name exists. Use it only for values that aren't secret (a URL or a timeout), never for a secret field. +- **Malformed references fail.** Anything between `<<` and `>>` that isn't a valid name is an error, as is `<` or `>`. To write a literal `<>`, for example in a tool description, write `<<>>`. +- **Unresolved references fail validation and deploy.** Saving a draft that references a missing entry works, but `dtwo-validate-gateway-config` and the next deploy report the missing names until the entry exists. Pass `gatewayUid` when validating, so entries kept on that gateway resolve too. +- **Scope.** An entry is either for all gateways or kept on one gateway (`gatewayUid`). A gateway's config and its sidecars can use both kinds. A one-gateway entry is deleted with its gateway, and can't share a name with an entry for all gateways. + +**Adding a secret.** No tool accepts a secret's value, so the user always types it into the Hub: + +1. Check `dtwo-list-vault-entries` with the `name` first. The search covers every gateway, so check each match's scope: reuse an entry only if it is for all gateways or its `gatewayUid` is the gateway you're configuring. An entry kept on another gateway can't resolve here; if those are the only matches, create one for this gateway in the next step. +2. Otherwise, write the reference into the config and call `dtwo-get-vault-secret-link` with the `name` (add `gatewayUid` to keep it on this gateway, and a short `description`). To replace an existing secret, pass only its `uid`. +3. Give the user the link and ask them to paste the secret there and save. The form opens only while they are signed in to the same Dtwo organization as this MCP session. +4. Once they say it's saved, confirm with `dtwo-list-vault-entries` by name, then validate. + +Never ask for a secret in the chat. If the user pastes one anyway, don't pass it to any tool, and give them the link instead. If they don't have the secret yet, leave the reference in the draft and give them the link for later; tell them the gateway can't deploy until the entry exists. If you're unsure whether a value is secret, treat it as one. + +**Plain entries.** For a non-secret value used by several configs (an upstream URL, a timeout), `dtwo-create-vault-entry` stores it as given. A reference resolves to a string, so it can't fill a numeric or boolean field yet. + +**Changing a value.** Gateway config and sidecars pick up a new value at different times: + +- **Gateway config.** Running gateways keep the old value until they are next deployed, so offer a redeploy after a value changes. +- **Sidecars.** A deploy doesn't rewrite a sidecar's environment, so a sidecar keeps the old value even after a redeploy. When a sidecar uses the changed entry, ask the user to run Update or Restart on that gateway in the Hub. + +**Drafting YAML for someone else to apply.** If the user wants a config to paste somewhere outside Dtwo rather than one saved to a gateway, a self-describing placeholder such as `REPLACE_WITH_GITHUB_TOKEN` is still acceptable there. Never use a placeholder in a config saved to a gateway: it deploys as literal text and the upstream rejects it. ### MCP Server Example @@ -424,8 +475,8 @@ mcp_servers: authentication: type: oauth grant_type: authorization_code - client_id: "your-client-id" - client_secret: "your-client-secret" + client_id: "your-client-id" # not a secret; use the app's real client ID + client_secret: <> # Vault reference; the user enters the value in the Hub authorization_url: https://slack.com/oauth/v2/authorize token_url: https://slack.com/api/oauth.v2.access redirect_uri: https://localhost/oauth/callback @@ -440,8 +491,8 @@ mcp_servers: ### Editing Configuration 1. Fetch the current draft with `dtwo-get-gateway-config` -2. Edit the YAML (add/modify MCP servers, change gateway settings) -3. Validate with `dtwo-validate-gateway-config` +2. Edit the YAML (add/modify MCP servers, change gateway settings). Put every credential in as a Vault reference, and get the user a Hub link for any secret that isn't in the Vault yet (see Secrets and the Vault) +3. Validate with `dtwo-validate-gateway-config`, passing `gatewayUid`. It also lists the Vault entries the config uses and reports any that are missing 4. Save with `dtwo-save-gateway-draft-config` 5. Deploy with `dtwo-deploy-gateway` to test the draft configuration 6. Once working as desired, publish with `dtwo-publish-gateway-config` @@ -474,6 +525,7 @@ After deploying a gateway with configuration changes: 2. Verify the gateway is heartbeating (check `lastSeenAt` in `dtwo-get-gateway`) 3. Test that MCP tools from each configured server are accessible and responding 4. If an MCP server uses OAuth, the user may need to re-authenticate after the server is added or its auth config changes +5. If a server's tools don't appear or calls fail, check the gateway's recent events with `dtwo-search-recent-events` (errors, discovery runs, and auth events from about the last 30 days; call `dtwo-get-event-schema` for the filterable fields). Use `dtwo-search-events` only for anything older, since it is much slower ## Limitations diff --git a/dtwo/skills/dtwo-gateway-config/schema-reference.json b/dtwo/skills/dtwo-gateway-config/schema-reference.json index f85d81f..1940fb8 100644 --- a/dtwo/skills/dtwo-gateway-config/schema-reference.json +++ b/dtwo/skills/dtwo-gateway-config/schema-reference.json @@ -135,7 +135,7 @@ "schemaDefault": null, "deployDefault": null, "target": null, - "description": "Human-gated session clearing (session-control) registration: the IdP app the browser ceremony authenticates against. Arms the platform clear tools and the browser clear ceremony when `intent.enabled: true`, or when `clearing.enabled: true` on a gateway that uses markers without intent capture. A gateway using Dtwo authentication needs no credentials here — it gets its organization's platform-provisioned app — but prefer `clearing: {enabled: true}` over an empty block, so the block states what it does: an empty one arms only while `intent.enabled` is true and parks inert otherwise. `client_id` is only needed for a gateway trusting a customer-run IdP, and `redirect_uri` only for one the browser reaches at a different origin than its token audience. The ceremony issuer is not configured here — it is always `gateway.authentication.jwks_info.jwt_issuer` (the same tenant issues both the inbound tokens and the browser-login ID tokens), and the deploy derives `SESSION_CONTROL_ISSUER` from it." + "description": "Human-gated session clearing (session-control) registration: the IdP app the browser ceremony authenticates against. Arms the platform clear tools and the browser clear ceremony when `intent.enabled: true`, or when `clearing.enabled: true` on a gateway that uses markers without intent capture. A gateway using Dtwo authentication needs no credentials here — it gets its organization's platform-provisioned app — but prefer `clearing: {enabled: true}` over an empty block, so the block states what it does: an empty one arms only while `intent.enabled` is true and parks inert otherwise. `client_id` is only needed for a gateway trusting a customer-run IdP, and `redirect_uri` only for one the browser reaches at a different origin than its token audience, or whose first `jwt_audience` is not an absolute URL (where it is required). The ceremony issuer is not configured here — it is always `gateway.authentication.jwks_info.jwt_issuer` (the same tenant issues both the inbound tokens and the browser-login ID tokens), and the deploy derives `SESSION_CONTROL_ISSUER` from it." }, { "name": "log_level", @@ -152,7 +152,7 @@ ], "audience": "user", "schemaDefault": null, - "deployDefault": "DEBUG", + "deployDefault": "INFO", "target": "LOG_LEVEL", "description": "ContextForge log level.", "rationale": "Raise to `DEBUG` or `TRACE` to diagnose a specific issue; lower to `WARNING` or `ERROR` to reduce log volume in production.", @@ -704,7 +704,7 @@ { "path": "gateway.session_control", "title": "gateway.session_control", - "description": "Human-gated session clearing (session-control) registration: the IdP app the browser ceremony authenticates against. Arms the platform clear tools and the browser clear ceremony when `intent.enabled: true`, or when `clearing.enabled: true` on a gateway that uses markers without intent capture. A gateway using Dtwo authentication needs no credentials here — it gets its organization's platform-provisioned app — but prefer `clearing: {enabled: true}` over an empty block, so the block states what it does: an empty one arms only while `intent.enabled` is true and parks inert otherwise. `client_id` is only needed for a gateway trusting a customer-run IdP, and `redirect_uri` only for one the browser reaches at a different origin than its token audience. The ceremony issuer is not configured here — it is always `gateway.authentication.jwks_info.jwt_issuer` (the same tenant issues both the inbound tokens and the browser-login ID tokens), and the deploy derives `SESSION_CONTROL_ISSUER` from it.", + "description": "Human-gated session clearing (session-control) registration: the IdP app the browser ceremony authenticates against. Arms the platform clear tools and the browser clear ceremony when `intent.enabled: true`, or when `clearing.enabled: true` on a gateway that uses markers without intent capture. A gateway using Dtwo authentication needs no credentials here — it gets its organization's platform-provisioned app — but prefer `clearing: {enabled: true}` over an empty block, so the block states what it does: an empty one arms only while `intent.enabled` is true and parks inert otherwise. `client_id` is only needed for a gateway trusting a customer-run IdP, and `redirect_uri` only for one the browser reaches at a different origin than its token audience, or whose first `jwt_audience` is not an absolute URL (where it is required). The ceremony issuer is not configured here — it is always `gateway.authentication.jwks_info.jwt_issuer` (the same tenant issues both the inbound tokens and the browser-login ID tokens), and the deploy derives `SESSION_CONTROL_ISSUER` from it.", "fields": [ { "name": "client_id", @@ -730,8 +730,8 @@ "schemaDefault": null, "deployDefault": null, "target": "SESSION_CONTROL_REDIRECT_URI", - "description": "Callback URL the browser clear ceremony redirects to, overriding the one the gateway derives from its own token audience. Must be an absolute `https://` URL (`http://` only on localhost, 127.0.0.1, or [::1]). Optional: leave it unset unless the browser reaches the gateway at a different origin than its audience. An explicit value always wins, and the platform allow-lists it verbatim on the IdP application.", - "rationale": "Leave unset in almost all cases — the gateway derives `/session-control/clear/callback` and the platform registers that. Set it when a reverse proxy or split-horizon DNS puts the browser on a different origin than the audience: give the URL the browser can actually reach, at the root path (`/mcp/*` routes to the streamable-HTTP handler, which rejects browser GETs).", + "description": "Callback URL the browser clear ceremony redirects to, overriding the one the gateway derives from its own token audience. Must be an absolute `https://` URL (`http://` only on localhost, 127.0.0.1, or [::1]). Optional: leave it unset unless the browser reaches the gateway at a different origin than its audience, or the first `jwt_audience` is not an absolute URL. Required while clearing is armed when the first `jwt_audience` is not an absolute URL (there is no origin to derive the callback from). An explicit value always wins, and the platform allow-lists it verbatim on the IdP application.", + "rationale": "Leave unset in almost all cases — the gateway derives `/session-control/clear/callback` and the platform registers that. Set it when a reverse proxy or split-horizon DNS puts the browser on a different origin than the audience: give the URL the browser can actually reach, at the root path (`/mcp/*` routes to the streamable-HTTP handler, which rejects browser GETs). It is required when the first `jwt_audience` is not an absolute URL — e.g. an Entra ID v2.0 gateway, whose audience is the gateway app's client ID (a GUID) — since there is no origin to derive from, and the deploy rejects an armed gateway that leaves it unset.", "targetKind": "envVar" }, { @@ -765,6 +765,9 @@ }, { "message": "While clearing is armed, `jwt_issuer` must be a normalized HTTPS URL (it becomes the ceremony issuer via `SESSION_CONTROL_ISSUER`) and `jwt_audience` must not be blank." + }, + { + "message": "While clearing is armed, `redirect_uri` is required when the first entry of `jwt_audience` is not an absolute `http(s)://` URL — for example an Entra ID v2.0 client ID or an `api://` Application ID URI: there is no browser-reachable origin to derive the callback from." } ] }, diff --git a/dtwo/skills/dtwo-gateway-policy/SKILL.md b/dtwo/skills/dtwo-gateway-policy/SKILL.md index 80d6e49..e50fc15 100644 --- a/dtwo/skills/dtwo-gateway-policy/SKILL.md +++ b/dtwo/skills/dtwo-gateway-policy/SKILL.md @@ -113,6 +113,20 @@ See **Quick start** above for the create→attach→deploy path and **Creating a | `dtwo-delete-policy` | Permanently delete a policy by UID. Fails if the policy is still attached to one or more gateways — detach it from every gateway first (see Deleting a Policy). Distinct from `dtwo-revert-policy`, which only restores a prior version | | `dtwo-list-claims` | Return the union of JWT claim names observed across the tenant, plus the issuers seen. Defaults to tenant-wide; pass `gatewayUid` to scope to a single gateway when the user asks. Call this when authoring or modifying identity-aware policies so rules can reference claims that actually exist; skip for policies that don't read `input.subject.claims`. | +### Catalog Policy Tools + +The policy catalog holds ready-made policies a tenant can import. An imported policy remembers which catalog version it came from, so later catalog changes can be offered as updates. + +| Tool | Purpose | +|------|---------| +| `dtwo-list-catalog-policies` / `dtwo-get-catalog-policy-filters` / `dtwo-get-catalog-policy` | Browse the catalog, the filters it supports, and one entry | +| `dtwo-import-catalog-policy` | Import a catalog policy as a tenant policy | +| `dtwo-check-policy-updates` | Compare imported policies with the catalog. Each reports `upToDate`, `updateAvailable`, or `removedUpstream`, plus `skipped` when the tenant already dismissed that exact version | +| `dtwo-apply-catalog-policy-update` | Pull the newer catalog version into the policy's **draft**. `applyPolicyCode` and `applyDescription` (both default `true`) choose which parts to replace; the result includes a diff of the Rego. No version is published unless `publish` is set. Call it only after the user has decided how to resolve the update | +| `dtwo-skip-policy-update` | Dismiss an available update until a newer catalog version appears. Doesn't change the policy, and the update can still be applied later | + +Never apply a catalog update on your own initiative. Show the user what changed (`dtwo-check-policy-updates`, then the diff), and remember that a pipeline step pinned to a published version keeps running the old Rego until the update is published and the gateway redeployed. + ### Marker Registry Tools Markers are session-state flags that one policy writes and other policies read to gate on (see Managing Markers). These tools are **always registered** on the Dtwo MCP server — they do not depend on any feature flag. @@ -366,6 +380,7 @@ After deploying a gateway: 4. Test individual policies in isolation first, then test the full pipeline — ordering of policies in a pipeline can affect the result (e.g., a transform in an earlier step may change data that a later step evaluates) 5. If a policy isn't working as expected: - **Blanket denies** (all tools blocked) — start with the Rego itself: a `default allow := false` with no rule that matches, or a syntax error anywhere in the bundle, which makes OPA fall back to denying everything. A step's `evalNamespace` differing from the policy's `package` is not a cause: the two are unrelated. + - **See what the gateway decided.** `dtwo-search-recent-events` returns the gateway's OPA policy decisions, errors, and auth events from about the last 30 days. Narrow it by event type, level, or gateway, and call `dtwo-get-event-schema` for the filterable fields. Matching is exact, so filter by fields rather than by words in the message. Use `dtwo-search-events` only for anything older; it is much slower - For deeper debugging, see the debugging guidance in the companion `dtwo-policy-rego` instructions (debug policies, blanket deny diagnosis) ## End-to-End Example diff --git a/dtwo/skills/setup/SKILL.md b/dtwo/skills/setup/SKILL.md index 4ba60ee..b14bb5f 100644 --- a/dtwo/skills/setup/SKILL.md +++ b/dtwo/skills/setup/SKILL.md @@ -148,6 +148,7 @@ Existing lifecycle tools this skill leans on (documented in the companion skills |------|---------| | `dtwo-list-gateways` / `dtwo-get-gateway` | Discover existing gateways; check provisioning/heartbeat state | | `dtwo-get-gateway-config` / `dtwo-save-gateway-draft-config` / `dtwo-validate-gateway-config` | Read, validate, and save the draft config (MCP servers) — via `dtwo-gateway-config` | +| `dtwo-list-vault-entries` / `dtwo-get-vault-secret-link` | Find an existing Vault entry, or get the Hub link where the user enters an upstream server's credential — via `dtwo-gateway-config` | | `dtwo-list-catalog-policies` / `dtwo-get-catalog-policy-filters` / `dtwo-import-catalog-policy` | Browse and import starter policies from the policy catalog | | `dtwo-add-policy` / `dtwo-publish-policy` | Create/publish a custom policy — via `dtwo-gateway-policy` + `dtwo-policy-rego` | | `dtwo-set-gateway-pipelines` / `dtwo-get-gateway-pipelines` | Attach imported/created policies to the ingress/egress pipelines — via `dtwo-gateway-policy` | @@ -297,9 +298,9 @@ MCP servers are the tools the gateway fronts. Start with the fastest path, then For each one the user picks, add an `mcp_servers` entry with just three fields: `name`, `url` (the table URL), and `transport_type: streamable_http` (the value the Dtwo Hub writes for these same servers; the schema also accepts `streamablehttp`, `http`, and `sse`, and the underscore form and the one-word form reach the gateway identically). Do not add an `authentication` block at all. These are public, anonymous servers, so they need no authentication, and leaving the block off is deliberate: writing `authentication: type: none` tells the gateway to forward your access token to the upstream, which public servers reject and which needlessly exposes your token. Omitting authentication is optional in the schema and validates cleanly. Use a kebab-cased id as the entry `name` (`context7`, `deepwiki`, `microsoft-learn`, `hugging-face`, `cloudflare-docs`, `gitmcp`), matching the Dtwo Hub. -**Custom servers (anything else).** Any MCP server can go behind the gateway; this is the normal path for real usage. Collect its URL and transport from the user. When the upstream needs credentials, add an `authentication` block; the **dtwo-gateway-config** skill owns the supported types (bearer token, basic, custom headers, query parameter, OAuth, certificate) and how to author them, including the secret-placeholder rules. For a public or no-auth upstream, omit the block entirely, the same as the quick-start entries above. One caveat to state plainly: some servers, for example Slack, GitHub, or Jira, assume the user has the right access and need extra configuration on the app side (OAuth apps, API tokens, workspace approval) before the gateway can reach them, so they take a few more steps than the quick-start list. +**Custom servers (anything else).** Any MCP server can go behind the gateway; this is the normal path for real usage. Collect its URL and transport from the user. When the upstream needs credentials, add an `authentication` block; the **dtwo-gateway-config** skill owns the supported types (bearer token, basic, custom headers, query parameter, OAuth, certificate) and how to author them, including how credentials go in. The short version: the config holds a Vault reference such as `<>`, and the user types the credential itself into the Dtwo Hub through a link you give them, never into the chat. Tell them the gateway can't deploy until that value is saved. For a public or no-auth upstream, omit the block entirely, the same as the quick-start entries above. One caveat to state plainly: some servers, for example Slack, GitHub, or Jira, assume the user has the right access and need extra configuration on the app side (OAuth apps, API tokens, workspace approval) before the gateway can reach them, so they take a few more steps than the quick-start list. -Offer to add more than one server, from either group. Load the **dtwo-gateway-config** skill and follow its flow to edit `mcp_servers`, then `dtwo-save-gateway-draft-config` + `dtwo-validate-gateway-config`. Do not restate the config schema here, that skill owns it (including transport type, outbound auth variants, and secret-placeholder rules). If the user has no server in mind yet, it's fine to save an empty `mcp_servers` and add one later, but tell them the gateway won't front anything until a server is added. +Offer to add more than one server, from either group. Load the **dtwo-gateway-config** skill and follow its flow to edit `mcp_servers`, then `dtwo-save-gateway-draft-config` + `dtwo-validate-gateway-config`. Do not restate the config schema here, that skill owns it (including transport type, outbound auth variants, and Vault references for credentials). If the user has no server in mind yet, it's fine to save an empty `mcp_servers` and add one later, but tell them the gateway won't front anything until a server is added. **Show progress — checkpoint 3 of 6, do this now.** Redraw the list with **Create your gateway**, **Configure auth**, and **Add MCP servers** checked off. Still to come: Add policies (Phase 10), Connect AI client (Phase 11), Test policies (Phase 12). diff --git a/scripts/generate-schema-digest.mjs b/scripts/generate-schema-digest.mjs index 67b38ba..290caf6 100644 --- a/scripts/generate-schema-digest.mjs +++ b/scripts/generate-schema-digest.mjs @@ -665,9 +665,9 @@ function renderSecretFields(filtered) { const rows = secrets.map(s => `| \`${s.section}.${s.field}\` |`); return [ - '#### Secret-typed fields — must emit a placeholder, never a literal', + '#### Secret-typed fields: must hold a Vault reference, never a literal', '', - 'Every field marked `secret: true` in the artifact. Emit a self-describing placeholder (`REPLACE_WITH_`, `PLACEHOLDER_`, `YOUR_`, `CHANGE_ME`, or `${ENV_VAR}`); never an inferred or literal credential. Bare `PLACEHOLDER` does not count.', + 'Every field marked `secret: true` in the artifact. Put a Vault reference there (`<>`, with no `|` fallback), never an inferred or literal credential. See Secrets and the Vault for how the user adds the value.', '', '| Path |', '|---|', diff --git a/skill-harness/README.md b/skill-harness/README.md index a0ecf85..a034337 100644 --- a/skill-harness/README.md +++ b/skill-harness/README.md @@ -22,7 +22,7 @@ failing check marks the sample as failed; the per-prompt pass-rate is | `no_hallucinated_keys` | Every path in the YAML resolves to a real field in `schema-reference.json` (record-leaf children accepted when they match the leaf's declared key pattern). | | `no_dropped_keys` | Round-trip path-set subtraction: `paths(input) \ paths(parsed)` must be empty. | | `safe_defaults_preserved` | A small curated seed list of safety-relevant defaults must not be silently weakened. The list has two halves: `SAFE_DEFAULT_SEEDS`, whose safe values are derived from the artifact's `schemaDefault` / `deployDefault`, and `GATEWAY_OWNED_SAFE_DEFAULTS`, a short explicit list for values the gateway applies at boot — hand-written as the harness's own opinion and cross-checked against the `gatewayDefault` the artifact declares for them. Fixtures opt out per-path with `expect.safe_default_opt_out`. | -| `secrets_are_placeholders` | Every `secret: true` leaf is absent or holds a placeholder matching `/^>`, no `\|` fallback), which is what the skill writes into a config saved to a gateway, or a placeholder matching `/^ { assert.deepEqual(findSecretViolations(parsed, secretPaths), []); }); + it('accepts a Vault reference', () => { + const parsed = { + mcp_servers: [{ authentication: { type: 'oauth', client_secret: '<>' } }], + }; + assert.deepEqual(findSecretViolations(parsed, secretPaths), []); + }); + + it('rejects a Vault reference with a fallback, and a scheme in front of one', () => { + assert.ok(SECRET_PLACEHOLDER_REGEX.test('<>')); + assert.ok(!SECRET_PLACEHOLDER_REGEX.test('<>')); + assert.ok(!SECRET_PLACEHOLDER_REGEX.test('Bearer <>')); + assert.ok(!SECRET_PLACEHOLDER_REGEX.test('<>')); + assert.ok(!SECRET_PLACEHOLDER_REGEX.test('<')); + }); + it('accepts CHANGE_ME placeholder', () => { const parsed = { mcp_servers: [{ authentication: { type: 'bearer', token: 'CHANGE_ME' } }], diff --git a/skill-harness/src/schemaArtifact.ts b/skill-harness/src/schemaArtifact.ts index 6ad37ed..d4643ed 100644 --- a/skill-harness/src/schemaArtifact.ts +++ b/skill-harness/src/schemaArtifact.ts @@ -45,7 +45,7 @@ export const SCHEMA_ARTIFACT_VERSION = '1.1.0'; * in reviewed source. It cannot stop a PR that bumps both the artifact and * this constant — judging that change is still review's job. */ -export const EXPECTED_SCHEMA_ARTIFACT_SHA256 = '22f5fa77bd79b0686b23f663756e787613547f2f2d65e8e98d2a5d012ec0e30e'; +export const EXPECTED_SCHEMA_ARTIFACT_SHA256 = '4dc79f71108e6c02642bd5fc8463e310924d0c3c453e634cd5afc60d9b46af16'; /** * `platform` is emitted by the currently vendored artifact (every diff --git a/skill-harness/src/secrets.ts b/skill-harness/src/secrets.ts index 64ebc50..5c8c55a 100644 --- a/skill-harness/src/secrets.ts +++ b/skill-harness/src/secrets.ts @@ -14,6 +14,11 @@ * (covers `YOUR_BEARER_TOKEN`, `your-instance`, * ``, etc.) * - `${…}` shell-style substitution markers + * - `<>` — a Vault reference, the form the skill now writes into + * a config saved to a gateway. Anchored at both ends: + * the whole value must be the reference, and a + * `<>` rejects, since its fallback would + * hold a literal secret. * * The bracket and bare-form tolerance was added after Tier-2 bench runs * showed the skill naturally emits all of these shapes interchangeably @@ -27,7 +32,8 @@ import { PRESERVE_CHILD_KEYS, splitVariantSuffix, stripVariantParentheticals } from './paths.js'; import type { SchemaArtifact } from './schemaArtifact.js'; -export const SECRET_PLACEHOLDER_REGEX = /^>$/i; /** * Compose every secret-flagged leaf path under its allowed (variant- diff --git a/skill-harness/src/validatorBundle.ts b/skill-harness/src/validatorBundle.ts index f8e927c..d65f4ed 100644 --- a/skill-harness/src/validatorBundle.ts +++ b/skill-harness/src/validatorBundle.ts @@ -39,7 +39,7 @@ export const EXPECTED_VALIDATOR_BUNDLE_VERSION = '4.0.0'; * `__tests__/schemaDigest.test.ts`, not at module load. Bump this together * with `EXPECTED_VALIDATOR_BUNDLE_VERSION` when the bundle is re-vendored. */ -export const EXPECTED_VALIDATOR_BUNDLE_SHA256 = '915727450d11614906e34260b742a3b7c814b58dbdbd6023c2f5af91e2e12ede'; +export const EXPECTED_VALIDATOR_BUNDLE_SHA256 = '3bd9f63afcc7c4cda244e2a7390e5797a3ee5574331455273390aa6bd63db16d'; const HERE = dirname(fileURLToPath(import.meta.url)); diff --git a/skill-harness/vendor/config-validator.bundle.mjs b/skill-harness/vendor/config-validator.bundle.mjs index dbf6261..d9a44ec 100644 --- a/skill-harness/vendor/config-validator.bundle.mjs +++ b/skill-harness/vendor/config-validator.bundle.mjs @@ -17134,6 +17134,16 @@ function isRenderableRedirectUri(value) { if (url2.protocol === "https:") return true; return url2.protocol === "http:" && REDIRECT_LOOPBACK_HOSTS.has(url2.hostname); } +function firstAudienceHasOrigin(audience) { + const first = audience.split(",")[0]?.trim() ?? ""; + let url2; + try { + url2 = new URL(first); + } catch { + return false; + } + return url2.host !== "" && (url2.protocol === "https:" || url2.protocol === "http:"); +} var SYMMETRIC_ALGORITHMS = ["HS256", "HS384", "HS512"]; var ASYMMETRIC_ALGORITHMS = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"]; var JWT_ALGORITHM_TYPES = [...SYMMETRIC_ALGORITHMS, ...ASYMMETRIC_ALGORITHMS]; @@ -17495,15 +17505,16 @@ var SessionControlSchema = external_exports.object({ message: "redirect_uri must be an absolute https:// URL (http:// only on localhost, 127.0.0.1, or [::1]) with no whitespace or control characters. It is rendered as a SESSION_CONTROL_REDIRECT_URI= line in the gateway env file; whitespace or a newline could inject a second assignment." }).optional().meta( meta3({ - description: "Callback URL the browser clear ceremony redirects to, overriding the one the gateway derives from its own token audience. Must be an absolute `https://` URL (`http://` only on localhost, 127.0.0.1, or [::1]). Optional: leave it unset unless the browser reaches the gateway at a different origin than its audience. An explicit value always wins, and the platform allow-lists it verbatim on the IdP application.", + description: "Callback URL the browser clear ceremony redirects to, overriding the one the gateway derives from its own token audience. Must be an absolute `https://` URL (`http://` only on localhost, 127.0.0.1, or [::1]). Optional: leave it unset unless the browser reaches the gateway at a different origin than its audience, or the first `jwt_audience` is not an absolute URL. Required while clearing is armed when the first `jwt_audience` is not an absolute URL (there is no origin to derive the callback from). An explicit value always wins, and the platform allow-lists it verbatim on the IdP application.", target: "SESSION_CONTROL_REDIRECT_URI", audience: "user", - rationale: "Leave unset in almost all cases \u2014 the gateway derives `/session-control/clear/callback` and the platform registers that. Set it when a reverse proxy or split-horizon DNS puts the browser on a different origin than the audience: give the URL the browser can actually reach, at the root path (`/mcp/*` routes to the streamable-HTTP handler, which rejects browser GETs)." + rationale: "Leave unset in almost all cases \u2014 the gateway derives `/session-control/clear/callback` and the platform registers that. Set it when a reverse proxy or split-horizon DNS puts the browser on a different origin than the audience: give the URL the browser can actually reach, at the root path (`/mcp/*` routes to the streamable-HTTP handler, which rejects browser GETs). It is required when the first `jwt_audience` is not an absolute URL \u2014 e.g. an Entra ID v2.0 gateway, whose audience is the gateway app's client ID (a GUID) \u2014 since there is no origin to derive from, and the deploy rejects an armed gateway that leaves it unset." }) ), // There is deliberately NO `issuer` field. The ceremony's issuer is always // `gateway.authentication.jwks_info.jwt_issuer` — the callback binds the - // inbound and browser-login identities as raw `iss|sub`, so any other value + // inbound and browser-login identities as raw `iss|` (`sub`, or `oid` + // on an Entra issuer), so any other value // makes every ceremony deterministically fail subject_mismatch. A field // whose only legal value is a copy of another field is pure misconfiguration // surface, so the deploy derives SESSION_CONTROL_ISSUER from jwt_issuer @@ -17515,14 +17526,15 @@ var SessionControlSchema = external_exports.object({ ) }).strict().meta( meta3({ - description: "Human-gated session clearing (session-control) registration: the IdP app the browser ceremony authenticates against. Arms the platform clear tools and the browser clear ceremony when `intent.enabled: true`, or when `clearing.enabled: true` on a gateway that uses markers without intent capture. A gateway using Dtwo authentication needs no credentials here \u2014 it gets its organization's platform-provisioned app \u2014 but prefer `clearing: {enabled: true}` over an empty block, so the block states what it does: an empty one arms only while `intent.enabled` is true and parks inert otherwise. `client_id` is only needed for a gateway trusting a customer-run IdP, and `redirect_uri` only for one the browser reaches at a different origin than its token audience. The ceremony issuer is not configured here \u2014 it is always `gateway.authentication.jwks_info.jwt_issuer` (the same tenant issues both the inbound tokens and the browser-login ID tokens), and the deploy derives `SESSION_CONTROL_ISSUER` from it.", + description: "Human-gated session clearing (session-control) registration: the IdP app the browser ceremony authenticates against. Arms the platform clear tools and the browser clear ceremony when `intent.enabled: true`, or when `clearing.enabled: true` on a gateway that uses markers without intent capture. A gateway using Dtwo authentication needs no credentials here \u2014 it gets its organization's platform-provisioned app \u2014 but prefer `clearing: {enabled: true}` over an empty block, so the block states what it does: an empty one arms only while `intent.enabled` is true and parks inert otherwise. `client_id` is only needed for a gateway trusting a customer-run IdP, and `redirect_uri` only for one the browser reaches at a different origin than its token audience, or whose first `jwt_audience` is not an absolute URL (where it is required). The ceremony issuer is not configured here \u2014 it is always `gateway.authentication.jwks_info.jwt_issuer` (the same tenant issues both the inbound tokens and the browser-login ID tokens), and the deploy derives `SESSION_CONTROL_ISSUER` from it.", crossFieldConstraints: [ "`clearing.enabled` unset follows `gateway.intent.enabled`; an explicit `true` arms clearing even with intent capture off (the markers-only deployment).", "`clearing.enabled: false` while `gateway.intent.enabled: true` is rejected (clearing cannot be withdrawn where markers can block).", "While clearing is armed, `gateway.authentication` must be enabled with `jwks_info` \u2014 the ceremony binds every clear to the authenticated caller identity, so an unauthenticated inbound leg has nothing to bind.", "A gateway that authenticates against an IdP other than Dtwo's Auth0 must set `client_id`: the platform provisions a ceremony app only in its own tenant, and the deploy fails rather than arming a gateway whose ceremony has no application to authenticate against.", "`redirect_uri`, when set, replaces the callback the gateway would derive from the first entry of `jwt_audience`, and is the URL the platform allow-lists on the IdP application. Unset, ordering within a comma-separated `jwt_audience` decides the callback.", - "While clearing is armed, `jwt_issuer` must be a normalized HTTPS URL (it becomes the ceremony issuer via `SESSION_CONTROL_ISSUER`) and `jwt_audience` must not be blank." + "While clearing is armed, `jwt_issuer` must be a normalized HTTPS URL (it becomes the ceremony issuer via `SESSION_CONTROL_ISSUER`) and `jwt_audience` must not be blank.", + "While clearing is armed, `redirect_uri` is required when the first entry of `jwt_audience` is not an absolute `http(s)://` URL \u2014 for example an Entra ID v2.0 client ID or an `api://` Application ID URI: there is no browser-reachable origin to derive the callback from." ] }) ); @@ -17576,7 +17588,7 @@ var GatewaySchema = external_exports.object({ meta3({ description: "ContextForge log level.", target: "LOG_LEVEL", - deployDefault: "DEBUG", + deployDefault: "INFO", audience: "user", rationale: "Raise to `DEBUG` or `TRACE` to diagnose a specific issue; lower to `WARNING` or `ERROR` to reduce log volume in production." }) @@ -17622,6 +17634,14 @@ var GatewaySchema = external_exports.object({ message: "gateway.authentication.jwks_info.jwt_audience is blank (whitespace only). While clearing is armed the inbound leg must validate real tokens \u2014 a blank audience matches none, so no caller identity would ever reach the clear mint. Set the audience the IdP targets at this gateway." }); } + const redirectUri = gw.session_control?.redirect_uri; + if (redirectUri === void 0 && jwks?.jwt_audience !== void 0 && jwks.jwt_audience.trim() !== "" && !firstAudienceHasOrigin(jwks.jwt_audience)) { + ctx.addIssue({ + code: "custom", + path: ["session_control", "redirect_uri"], + message: "While clearing is armed, gateway.session_control.redirect_uri is required when the first gateway.authentication.jwks_info.jwt_audience entry is not an absolute http(s):// URL \u2014 for example an Entra ID v2.0 client ID or an api:// Application ID URI: there is no browser-reachable origin to derive the clear callback from. Set redirect_uri to the URL the browser reaches, e.g. https:///session-control/clear/callback." + }); + } } }); var TRANSPORT_TYPES = ["sse", "streamable_http", "streamablehttp", "http"]; @@ -18016,22 +18036,23 @@ var ConfigSchema = external_exports.preprocess( }).strict() ); var MAX_CONFIG_LENGTH = 256 * 1024; -function parseConfig(raw) { +function loadConfigDocument(raw) { if (raw.length > MAX_CONFIG_LENGTH) { return { success: false, error: `Config exceeds the maximum supported length of ${MAX_CONFIG_LENGTH} characters.` }; } - let parsed; try { - parsed = yaml.load(raw); + return { success: true, data: yaml.load(raw) }; } catch (e) { return { success: false, error: `Invalid YAML: ${e instanceof Error ? e.message : String(e)}` }; } +} +function validateConfigDocument(document) { let result; try { - result = ConfigSchema.safeParse(parsed); + result = ConfigSchema.safeParse(document); } catch (e) { return { success: false, error: `Invalid config: ${e instanceof Error ? e.message : String(e)}` }; } @@ -18040,6 +18061,11 @@ function parseConfig(raw) { } return { success: true, data: result.data }; } +function parseConfig(raw) { + const loaded = loadConfigDocument(raw); + if (!loaded.success) return loaded; + return validateConfigDocument(loaded.data); +} // src/internal/validator-bundle-entry.ts var VALIDATOR_BUNDLE_VERSION = "4.0.0";