From 526b8f1a88961bebf1d605f5558bea35233c3814 Mon Sep 17 00:00:00 2001 From: Zakk Date: Tue, 29 Sep 2026 07:39:41 +1000 Subject: [PATCH 1/7] fix(api): align conditional requests and statuses with the HTTP RFCs - IpAddress uses anyOf; the ipv4/ipv6 branches differ only by format. - Node and provider create/delete report a configuration change during admission as 409 state_conflict; 412 is only for a failed If-Match. - JSON Patch operation objects ignore members they do not define (RFC 6902 section 4). - If-Match follows RFC 9110 section 13.1.1: `*`, tag lists and weak tags are well-formed and are compared, not rejected as malformed. - GET /config/sources/{source_id} sends the content hash as ETag. - A known path with an unsupported method returns 405 with Allow. - errors.md states once that body checks precede the 412 precondition. - Every 401 carries WWW-Authenticate: Bearer. - A non-loopback listener needs a deployment secret or password mode. --- api/common.yaml | 21 ++++- api/config.yaml | 63 +++---------- api/nodes-groups.yaml | 20 +---- api/openapi.yaml | 5 +- api/providers.yaml | 8 +- source/openapi.yaml | 119 +++++++++---------------- source/v0.1.0/en/docs/api-config.md | 9 +- source/v0.1.0/en/docs/configuration.md | 4 +- source/v0.1.0/en/docs/errors.md | 36 ++++++-- source/v0.1.0/en/docs/groups.md | 3 +- tools/check-contract.test.mjs | 19 ++-- 11 files changed, 138 insertions(+), 169 deletions(-) diff --git a/api/common.yaml b/api/common.yaml index 5d48db7..ee44724 100644 --- a/api/common.yaml +++ b/api/common.yaml @@ -24,7 +24,7 @@ headers: type: string const: nosniff ETag: - description: Quoted group configuration revision. + description: Strong entity tag of the representation, the value a later If-Match compares. required: true schema: type: string @@ -106,9 +106,9 @@ parameters: name: If-Match in: header required: true + description: Evaluated as RFC 9110 §13.1.1 defines; see Conditional requests in the error contract. schema: - type: string - minLength: 1 + $ref: ./openapi.yaml#/components/schemas/EntityTagList example: '"17"' IdempotencyKey: name: Idempotency-Key @@ -196,6 +196,12 @@ responses: Unauthorized: description: Credentials are missing or invalid headers: + WWW-Authenticate: + description: The authentication challenge (RFC 9110 §15.5.2); the native API uses the Bearer scheme. + required: true + schema: + type: string + pattern: ^Bearer(\s|$) Cache-Control: $ref: ./openapi.yaml#/components/headers/NoStore X-Content-Type-Options: @@ -214,6 +220,7 @@ responses: request_id: request-01HZX4K8W6 x-headers: Content-Type: application/json + WWW-Authenticate: Bearer Cache-Control: no-store X-Content-Type-Options: nosniff Forbidden: @@ -548,6 +555,7 @@ schemas: - permission_denied - resource_not_found - capability_not_supported + - method_not_allowed - state_conflict - idempotency_conflict - event_cursor_expired @@ -660,8 +668,13 @@ schemas: IpVersion: type: string enum: [ ipv4, ipv6 ] + EntityTagList: + type: string + description: "`*` or a comma-separated list of entity tags, strong or weak (W/ prefix)." + pattern: '^(\*|(W/)?"[^"]*"([ \t]*,[ \t]*(W/)?"[^"]*")*)$' IpAddress: - oneOf: + description: An IPv4 or IPv6 address. The two branches differ only by format, so validating this value requires asserting the ipv4 and ipv6 formats. + anyOf: - type: string format: ipv4 - type: string diff --git a/api/config.yaml b/api/config.yaml index 3b4b461..26e99af 100644 --- a/api/config.yaml +++ b/api/config.yaml @@ -79,25 +79,7 @@ paths: "400": $ref: ./openapi.yaml#/components/responses/BadRequest "401": - description: Credentials are missing or invalid - headers: - Cache-Control: - $ref: ./openapi.yaml#/components/headers/NoStore - X-Content-Type-Options: - $ref: ./openapi.yaml#/components/headers/NoSniff - content: - application/json: - schema: - $ref: ./openapi.yaml#/components/schemas/ErrorResponse - examples: - authentication_required: - value: - error: { code: authentication_required, message: Credentials are required. } - request_id: request-config-1 - x-headers: - Content-Type: application/json - Cache-Control: no-store - X-Content-Type-Options: nosniff + $ref: ./openapi.yaml#/components/responses/Unauthorized "403": description: Authenticated caller lacks observe permission headers: @@ -433,6 +415,9 @@ paths: "200": description: Accepted source; content remains subject to visibility policy headers: + ETag: + $ref: ./openapi.yaml#/components/headers/ETag + description: The source's content_sha256 in double quotes, the value PUT compares in If-Match. Cache-Control: $ref: ./openapi.yaml#/components/headers/NoStore X-Content-Type-Options: @@ -456,6 +441,7 @@ paths: line_count: 3 x-headers: Content-Type: application/json + ETag: '"d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1"' Cache-Control: no-store X-Content-Type-Options: nosniff redacted: @@ -471,6 +457,7 @@ paths: line_count: 8 x-headers: Content-Type: application/json + ETag: '"0000000000000000000000000000000000000000000000000000000000000000"' Cache-Control: no-store X-Content-Type-Options: nosniff "400": @@ -569,18 +556,14 @@ paths: - bearerAuth: [] - {} parameters: - - name: If-Match - in: header - required: true + - $ref: ./openapi.yaml#/components/parameters/IfMatch description: | - One strong entity tag containing the source's content_sha256 from - GET /config, enclosed in double quotes. Compare the digest with the - source's current bytes in the configuration store, not the snapshot revision. Wildcards, weak - tags, and tag lists are not accepted. - schema: - type: string - pattern: '^"[0-9a-f]{64}"$' - example: '"d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1"' + The source's content_sha256, from GET /config or the ETag of + GET /config/sources/{source_id}, enclosed in double quotes. The server + compares it with the source's current bytes in the configuration store, + not the snapshot revision, as RFC 9110 §13.1.1 defines: `*` matches the + existing source, a list matches when any strong tag in it matches, and a + weak tag never matches. - $ref: ./openapi.yaml#/components/parameters/IdempotencyKey requestBody: required: true @@ -861,25 +844,7 @@ paths: Cache-Control: no-store X-Content-Type-Options: nosniff "401": - description: Credentials are missing or invalid - headers: - Cache-Control: - $ref: ./openapi.yaml#/components/headers/NoStore - X-Content-Type-Options: - $ref: ./openapi.yaml#/components/headers/NoSniff - content: - application/json: - schema: - $ref: ./openapi.yaml#/components/schemas/ErrorResponse - examples: - authentication_required: - value: - error: { code: authentication_required, message: Credentials are required. } - request_id: request-validate-2 - x-headers: - Content-Type: application/json - Cache-Control: no-store - X-Content-Type-Options: nosniff + $ref: ./openapi.yaml#/components/responses/Unauthorized "403": description: Authenticated caller lacks control permission headers: diff --git a/api/nodes-groups.yaml b/api/nodes-groups.yaml index f8cceae..a26e86a 100644 --- a/api/nodes-groups.yaml +++ b/api/nodes-groups.yaml @@ -82,7 +82,8 @@ paths: generation.changed. The response does not echo the link; the source text returns it as written (see Visibility in API Configuration). A link the engine cannot parse returns 422 unsupported_value with a sanitized reason in - error.message. A name already in use returns 409 state_conflict. The node belongs to the + error.message. A name already in use, or a configuration change while the + create is being admitted, returns 409 state_conflict. The node belongs to the inline provider and to every group whose filter matches it after reload. Returns 201 with the node once the change is active, or 202 with a node_create operation whose result is the created node. A failed @@ -407,10 +408,7 @@ paths: $ref: ./openapi.yaml#/components/responses/NotFound "409": $ref: ./openapi.yaml#/components/responses/Conflict - description: A group or the routing final outbound still names the node (state_conflict); nothing is removed. Filter-matched membership is not a reference. - "412": - $ref: ./openapi.yaml#/components/responses/PreconditionFailed - description: The configuration changed while the delete was being admitted (stale_revision); nothing is removed. Read the node back and retry. + description: A group or the routing final outbound still names the node, or the configuration changed while the delete was being admitted (state_conflict); nothing is removed. Filter-matched membership is not a reference. After a concurrent change, read the node back and retry. "413": $ref: ./openapi.yaml#/components/responses/TooLarge "503": @@ -1194,10 +1192,9 @@ schemas: - $ref: ./openapi.yaml#/components/schemas/InterruptPatch - $ref: ./openapi.yaml#/components/schemas/RemovePatch - $ref: ./openapi.yaml#/components/schemas/CopyMovePatch - description: RFC 6902 operations over the group's policy and config, applied in order. Bounded by resources.groups.max_patch_operations. + description: RFC 6902 operations over the group's policy and config, applied in order. Bounded by resources.groups.max_patch_operations. Members an operation object does not define are ignored (RFC 6902 §4). PolicyPatch: type: object - additionalProperties: false required: [ op, path, value ] properties: op: @@ -1210,7 +1207,6 @@ schemas: $ref: "./openapi.yaml#/components/schemas/GroupPolicyRequest" MemberIdPatch: type: object - additionalProperties: false required: [ op, path, value ] properties: op: @@ -1223,7 +1219,6 @@ schemas: type: [ string, "null" ] OutboundPatch: type: object - additionalProperties: false required: [ op, path, value ] properties: op: @@ -1236,7 +1231,6 @@ schemas: type: [ string, "null" ] CheckUrlPatch: type: object - additionalProperties: false required: [ op, path, value ] properties: op: @@ -1251,7 +1245,6 @@ schemas: - $ref: "./openapi.yaml#/components/schemas/SafeHttpUrl" PositiveIntegerPatch: type: object - additionalProperties: false required: [ op, path, value ] properties: op: @@ -1265,7 +1258,6 @@ schemas: minimum: 1 TolerancePatch: type: object - additionalProperties: false required: [ op, path, value ] properties: op: @@ -1279,7 +1271,6 @@ schemas: minimum: 0 IdleTimeoutPatch: type: object - additionalProperties: false required: [ op, path, value ] properties: op: @@ -1293,7 +1284,6 @@ schemas: minimum: 0 InterruptPatch: type: object - additionalProperties: false required: [ op, path, value ] properties: op: @@ -1309,7 +1299,6 @@ schemas: enum: [ /policy, /config/default_member_id, /config/final_outbound, /config/check_url, /config/check_interval, /config/tolerance, /config/idle_timeout, /config/interrupt_connections ] RemovePatch: type: object - additionalProperties: false required: [ op, path ] properties: op: @@ -1318,7 +1307,6 @@ schemas: $ref: ./openapi.yaml#/components/schemas/MutableGroupPath CopyMovePatch: type: object - additionalProperties: false required: [ op, path, from ] properties: op: diff --git a/api/openapi.yaml b/api/openapi.yaml index 4a66f31..0bfe3a8 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5,7 +5,8 @@ info: version: 0.1.0-draft description: | Normative wire contract for the native control-plane API. Unknown response - extension fields are permitted. Request objects reject unknown fields. + extension fields are permitted. Request objects reject unknown fields, + except JSON Patch operation objects, which ignore them as RFC 6902 requires. Published as a generated bundle at source/openapi.yaml; edit the api/ sources, not the published file. servers: @@ -506,6 +507,8 @@ components: $ref: ./flow-steps.yaml#/schemas/RuleCondition RuleEvaluation: $ref: ./flow-steps.yaml#/schemas/RuleEvaluation + EntityTagList: + $ref: ./common.yaml#/schemas/EntityTagList IpAddress: $ref: ./common.yaml#/schemas/IpAddress RoutingTraceInput: diff --git a/api/providers.yaml b/api/providers.yaml index 9d31dba..c87d941 100644 --- a/api/providers.yaml +++ b/api/providers.yaml @@ -80,7 +80,8 @@ paths: POST /providers/{provider_id}/refresh to load it. Otherwise the backend may fetch the provider during activation and returns its actual state. The response carries the URL in url_redacted, as written apart from listener secrets. A name already in use - returns 409 state_conflict. A URL that is not HTTP(S) returns 422 unsupported_value. + returns 409 state_conflict, as does a configuration change while the create is + being admitted. A URL that is not HTTP(S) returns 422 unsupported_value. update_interval, user_agent and cache are accepted only when named in resources.providers.create_options; an omitted one takes the default listed there, and one the backend does not list returns 422 unsupported_value. @@ -424,10 +425,7 @@ paths: $ref: ./openapi.yaml#/components/responses/NotFound "409": $ref: ./openapi.yaml#/components/responses/Conflict - description: The configuration still names the provider in a reference the delete would break (state_conflict); nothing is removed. Filter-matched membership is not a reference. - "412": - $ref: ./openapi.yaml#/components/responses/PreconditionFailed - description: The configuration changed while the delete was being admitted (stale_revision); nothing is removed. Read the provider back and retry. + description: The configuration still names the provider in a reference the delete would break, or the configuration changed while the delete was being admitted (state_conflict); nothing is removed. Filter-matched membership is not a reference. After a concurrent change, read the provider back and retry. "413": $ref: ./openapi.yaml#/components/responses/TooLarge "503": diff --git a/source/openapi.yaml b/source/openapi.yaml index fc54502..2c1e3e5 100644 --- a/source/openapi.yaml +++ b/source/openapi.yaml @@ -4,7 +4,8 @@ info: version: 0.1.0-draft description: | Normative wire contract for the native control-plane API. Unknown response - extension fields are permitted. Request objects reject unknown fields. + extension fields are permitted. Request objects reject unknown fields, + except JSON Patch operation objects, which ignore them as RFC 6902 requires. Published as a generated bundle at source/openapi.yaml; edit the api/ sources, not the published file. jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema @@ -612,27 +613,7 @@ paths: '400': $ref: '#/components/responses/BadRequest' '401': - description: Credentials are missing or invalid - headers: - Cache-Control: - $ref: '#/components/headers/NoStore' - X-Content-Type-Options: - $ref: '#/components/headers/NoSniff' - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - examples: - authentication_required: - value: - error: - code: authentication_required - message: Credentials are required. - request_id: request-config-1 - x-headers: - Content-Type: application/json - Cache-Control: no-store - X-Content-Type-Options: nosniff + $ref: '#/components/responses/Unauthorized' '403': description: Authenticated caller lacks observe permission headers: @@ -823,27 +804,7 @@ paths: Cache-Control: no-store X-Content-Type-Options: nosniff '401': - description: Credentials are missing or invalid - headers: - Cache-Control: - $ref: '#/components/headers/NoStore' - X-Content-Type-Options: - $ref: '#/components/headers/NoSniff' - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - examples: - authentication_required: - value: - error: - code: authentication_required - message: Credentials are required. - request_id: request-validate-2 - x-headers: - Content-Type: application/json - Cache-Control: no-store - X-Content-Type-Options: nosniff + $ref: '#/components/responses/Unauthorized' '403': description: Authenticated caller lacks control permission headers: @@ -1276,6 +1237,9 @@ paths: '200': description: Accepted source; content remains subject to visibility policy headers: + ETag: + $ref: '#/components/headers/ETag' + description: The source's content_sha256 in double quotes, the value PUT compares in If-Match. Cache-Control: $ref: '#/components/headers/NoStore' X-Content-Type-Options: @@ -1302,6 +1266,7 @@ paths: line_count: 3 x-headers: Content-Type: application/json + ETag: '"d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1"' Cache-Control: no-store X-Content-Type-Options: nosniff redacted: @@ -1325,6 +1290,7 @@ paths: line_count: 8 x-headers: Content-Type: application/json + ETag: '"0000000000000000000000000000000000000000000000000000000000000000"' Cache-Control: no-store X-Content-Type-Options: nosniff '400': @@ -1427,18 +1393,14 @@ paths: - bearerAuth: [] - {} parameters: - - name: If-Match - in: header - required: true + - $ref: '#/components/parameters/IfMatch' description: | - One strong entity tag containing the source's content_sha256 from - GET /config, enclosed in double quotes. Compare the digest with the - source's current bytes in the configuration store, not the snapshot revision. Wildcards, weak - tags, and tag lists are not accepted. - schema: - type: string - pattern: ^"[0-9a-f]{64}"$ - example: '"d1f62f00c6da9ec33956e66b8cc3b4670f164556fc12453193904af23451dec1"' + The source's content_sha256, from GET /config or the ETag of + GET /config/sources/{source_id}, enclosed in double quotes. The server + compares it with the source's current bytes in the configuration store, + not the snapshot revision, as RFC 9110 §13.1.1 defines: `*` matches the + existing source, a list matches when any strong tag in it matches, and a + weak tag never matches. - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true @@ -2209,7 +2171,8 @@ paths: generation.changed. The response does not echo the link; the source text returns it as written (see Visibility in API Configuration). A link the engine cannot parse returns 422 unsupported_value with a sanitized reason in - error.message. A name already in use returns 409 state_conflict. The node belongs to the + error.message. A name already in use, or a configuration change while the + create is being admitted, returns 409 state_conflict. The node belongs to the inline provider and to every group whose filter matches it after reload. Returns 201 with the node once the change is active, or 202 with a node_create operation whose result is the created node. A failed @@ -2485,7 +2448,8 @@ paths: POST /providers/{provider_id}/refresh to load it. Otherwise the backend may fetch the provider during activation and returns its actual state. The response carries the URL in url_redacted, as written apart from listener secrets. A name already in use - returns 409 state_conflict. A URL that is not HTTP(S) returns 422 unsupported_value. + returns 409 state_conflict, as does a configuration change while the create is + being admitted. A URL that is not HTTP(S) returns 422 unsupported_value. update_interval, user_agent and cache are accepted only when named in resources.providers.create_options; an omitted one takes the default listed there, and one the backend does not list returns 422 unsupported_value. @@ -2833,10 +2797,7 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' - description: The configuration still names the provider in a reference the delete would break (state_conflict); nothing is removed. Filter-matched membership is not a reference. - '412': - $ref: '#/components/responses/PreconditionFailed' - description: The configuration changed while the delete was being admitted (stale_revision); nothing is removed. Read the provider back and retry. + description: The configuration still names the provider in a reference the delete would break, or the configuration changed while the delete was being admitted (state_conflict); nothing is removed. Filter-matched membership is not a reference. After a concurrent change, read the provider back and retry. '413': $ref: '#/components/responses/TooLarge' '503': @@ -3114,10 +3075,7 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' - description: A group or the routing final outbound still names the node (state_conflict); nothing is removed. Filter-matched membership is not a reference. - '412': - $ref: '#/components/responses/PreconditionFailed' - description: The configuration changed while the delete was being admitted (stale_revision); nothing is removed. Read the node back and retry. + description: A group or the routing final outbound still names the node, or the configuration changed while the delete was being admitted (state_conflict); nothing is removed. Filter-matched membership is not a reference. After a concurrent change, read the node back and retry. '413': $ref: '#/components/responses/TooLarge' '503': @@ -6565,7 +6523,7 @@ components: type: string const: nosniff ETag: - description: Quoted group configuration revision. + description: Strong entity tag of the representation, the value a later If-Match compares. required: true schema: type: string @@ -6666,9 +6624,9 @@ components: name: If-Match in: header required: true + description: Evaluated as RFC 9110 §13.1.1 defines; see Conditional requests in the error contract. schema: - type: string - minLength: 1 + $ref: '#/components/schemas/EntityTagList' example: '"17"' IdempotencyKey: name: Idempotency-Key @@ -6788,6 +6746,12 @@ components: Unauthorized: description: Credentials are missing or invalid headers: + WWW-Authenticate: + description: The authentication challenge (RFC 9110 §15.5.2); the native API uses the Bearer scheme. + required: true + schema: + type: string + pattern: ^Bearer(\s|$) Cache-Control: $ref: '#/components/headers/NoStore' X-Content-Type-Options: @@ -6806,6 +6770,7 @@ components: request_id: request-01HZX4K8W6 x-headers: Content-Type: application/json + WWW-Authenticate: Bearer Cache-Control: no-store X-Content-Type-Options: nosniff Forbidden: @@ -7135,6 +7100,7 @@ components: - permission_denied - resource_not_found - capability_not_supported + - method_not_allowed - state_conflict - idempotency_conflict - event_cursor_expired @@ -10325,10 +10291,9 @@ components: - $ref: '#/components/schemas/InterruptPatch' - $ref: '#/components/schemas/RemovePatch' - $ref: '#/components/schemas/CopyMovePatch' - description: RFC 6902 operations over the group's policy and config, applied in order. Bounded by resources.groups.max_patch_operations. + description: RFC 6902 operations over the group's policy and config, applied in order. Bounded by resources.groups.max_patch_operations. Members an operation object does not define are ignored (RFC 6902 §4). PolicyPatch: type: object - additionalProperties: false required: - op - path @@ -10347,7 +10312,6 @@ components: $ref: '#/components/schemas/GroupPolicyRequest' MemberIdPatch: type: object - additionalProperties: false required: - op - path @@ -10368,7 +10332,6 @@ components: - 'null' OutboundPatch: type: object - additionalProperties: false required: - op - path @@ -10389,7 +10352,6 @@ components: - 'null' CheckUrlPatch: type: object - additionalProperties: false required: - op - path @@ -10410,7 +10372,6 @@ components: - $ref: '#/components/schemas/SafeHttpUrl' PositiveIntegerPatch: type: object - additionalProperties: false required: - op - path @@ -10433,7 +10394,6 @@ components: minimum: 1 TolerancePatch: type: object - additionalProperties: false required: - op - path @@ -10455,7 +10415,6 @@ components: minimum: 0 IdleTimeoutPatch: type: object - additionalProperties: false required: - op - path @@ -10477,7 +10436,6 @@ components: minimum: 0 InterruptPatch: type: object - additionalProperties: false required: - op - path @@ -10509,7 +10467,6 @@ components: - /config/interrupt_connections RemovePatch: type: object - additionalProperties: false required: - op - path @@ -10520,7 +10477,6 @@ components: $ref: '#/components/schemas/MutableGroupPath' CopyMovePatch: type: object - additionalProperties: false required: - op - path @@ -12260,8 +12216,13 @@ components: type: array items: $ref: '#/components/schemas/RuleCondition' + EntityTagList: + type: string + description: '`*` or a comma-separated list of entity tags, strong or weak (W/ prefix).' + pattern: ^(\*|(W/)?"[^"]*"([ \t]*,[ \t]*(W/)?"[^"]*")*)$ IpAddress: - oneOf: + description: An IPv4 or IPv6 address. The two branches differ only by format, so validating this value requires asserting the ipv4 and ipv6 formats. + anyOf: - type: string format: ipv4 - type: string diff --git a/source/v0.1.0/en/docs/api-config.md b/source/v0.1.0/en/docs/api-config.md index e8fe570..5b7ba40 100644 --- a/source/v0.1.0/en/docs/api-config.md +++ b/source/v0.1.0/en/docs/api-config.md @@ -16,7 +16,8 @@ the native contract because they make binding and authorization ambiguous. Configure the native listener under `experimental.native_api`. Its settings include `enabled`, `listen`, `secret`, `allow_origins`, and `ui`. Use an explicit -loopback address for local access. A non-loopback listener requires a secret. +loopback address for local access. A non-loopback listener requires a secret or +password mode. `experimental.clash_api.external_controller` configures the separate Clash-compatible listener. @@ -24,7 +25,8 @@ Clash-compatible listener. ## Listener and authentication rules - Omitting `listen` binds only to loopback. -- A non-loopback listener requires `secret`; otherwise startup fails closed. +- A non-loopback listener requires deployment-secret authentication (`secret`) + or password authentication; otherwise startup fails closed. - `secret` is opaque. Implementations may enforce a minimum entropy policy but must not require one specific textual encoding. - Authentication uses `Authorization: Bearer `. Secrets must not appear @@ -77,7 +79,8 @@ The native API defines two permissions: The deployment secret and a password session grant `control`. Implementations may support additional observe-only credentials, but must preserve these -permission names. Missing or invalid required credentials return `401`. An +permission names. Missing or invalid required credentials return `401` with +`WWW-Authenticate: Bearer`. An authenticated caller without the required permission normally receives `403 permission_denied`. Operation reads instead return `404 resource_not_found` for an operation the caller cannot see. diff --git a/source/v0.1.0/en/docs/configuration.md b/source/v0.1.0/en/docs/configuration.md index a1714f0..5284b7b 100644 --- a/source/v0.1.0/en/docs/configuration.md +++ b/source/v0.1.0/en/docs/configuration.md @@ -107,7 +107,9 @@ allowed length. Content that JSON escaping expands can still exceed the body limit, and that limit then applies. Exceeding either returns `413 request_too_large`. -`If-Match` accepts one quoted strong tag, not a wildcard, weak tag, or tag list. +`GET /config/sources/{source_id}` returns the quoted `content_sha256` in `ETag`. +`If-Match` is evaluated as [conditional requests](errors.html#Conditional-requests) +defines. The optional `Idempotency-Key` follows the [operation rules](operations.html): within the running instance's retention window, the same caller, method, path, key, and body return the original operation without another write or hash diff --git a/source/v0.1.0/en/docs/errors.md b/source/v0.1.0/en/docs/errors.md index b114623..3de7bf0 100644 --- a/source/v0.1.0/en/docs/errors.md +++ b/source/v0.1.0/en/docs/errors.md @@ -32,14 +32,15 @@ for a failed configuration change listed under | 403 | `permission_denied` | The caller lacks the required permission, or listener security policy rejects the request. | | 404 | `resource_not_found` | The requested resource does not exist. | | 404 | `capability_not_supported` | The running adapter does not expose the resource or action. | -| 409 | `state_conflict` | The current state prevents the request: a name in use, a referenced object that is not current, or a transition the current state does not allow. | +| 405 | `method_not_allowed` | The path exists but does not support the request method; the response lists the supported methods in `Allow`. | +| 409 | `state_conflict` | The current state prevents the request: a name in use, a referenced object that is not current, a transition the current state does not allow, or a configuration change while a write without `If-Match` was being admitted. | | 409 | `idempotency_conflict` | An idempotency key was reused with a different request body. | | 409 | `event_cursor_expired` | Event or log SSE cursor cannot be replayed; open a fresh stream and establish a new baseline. | | 409 | `setup_required` | Password login was requested before an administrator was created. | | 409 | `setup_already_completed` | Administrator setup was requested after an administrator was created. | | 410 | `snapshot_expired` | A page cursor is no longer usable; restart the page walk. | | 410 | `flow_expired` | Flow evidence was evicted/expired and a tombstone still exists. | -| 412 | `stale_revision` | `If-Match` does not match the current resource revision or stored source content hash, or the configuration changed while a delete was being admitted. | +| 412 | `stale_revision` | `If-Match` does not match the current resource revision or stored source content hash. | | 413 | `request_too_large` | Request or requested fan-out exceeds an advertised limit. | | 415 | `unsupported_media_type` | Request `Content-Type` is unsupported. | | 422 | `unsupported_value` | The request is well-formed but the engine does not support its meaning, or full validation of a configuration candidate found error diagnostics. | @@ -57,8 +58,9 @@ every request, and a `GET` with a body is malformed. A server checks a request in this order and returns the status of the first check that fails: -1. Authentication, authorization and routing: `401`, `403`, and `404` for an - unknown route or an unadvertised capability. +1. Authentication, authorization and routing: `401`, `403`, `404` for an + unknown route or an unadvertised capability, and `405` with `Allow` for a + known path that does not support the method. 2. Request boundary: a missing required `If-Match` (`428`) or a malformed one (`400`), then `Content-Type` (`415`), body size (`413`), and parameter and body schema (`400`). @@ -84,8 +86,9 @@ cases fall in which row. | Status | Code | The request fails because | |--------|------|---------------------------| -| 400 | `invalid_request` | It cannot be parsed, or a parameter or field is outside its schema: wrong type, a missing field, a field the schema does not define, a value outside the schema's enum, range or length, or a scalar value above a bound the capabilities advertise, such as a page `limit` above `max_page_size`. A page cursor sent with different filters or a different `limit` is also `400`. | +| 400 | `invalid_request` | It cannot be parsed, or a parameter or field is outside its schema: wrong type, a missing field, a field the schema does not define (JSON Patch operation objects ignore such members instead), a value outside the schema's enum, range or length, or a scalar value above a bound the capabilities advertise, such as a page `limit` above `max_page_size`. A page cursor sent with different filters or a different `limit` is also `400`. | | 413 | `request_too_large` | The payload, or the fan-out the request asks for, exceeds an advertised bound: the body size, the number of operations in a group patch (`max_patch_operations`), the matching live entries a bulk close selects, including non-closable ones (`max_bulk_close`), or the targets or results of a probe or trace. | +| 405 | `method_not_allowed` | The path exists, but not with this method ([RFC 9110 §15.5.6](https://www.rfc-editor.org/rfc/rfc9110#section-15.5.6)). An unknown path is `404`. | | 428 | `precondition_required` | A required `If-Match` header is missing. | | 412 | `stale_revision` | `If-Match` names a revision or content hash that is no longer current. | | 422 | `unsupported_value` | It is well-formed and within every bound, but this engine does not support its meaning: an enum member or field the schema defines and the capabilities do not advertise, or a combination of fields or capabilities the engine does not implement. Error diagnostics from full validation of a configuration candidate are also `422`. | @@ -103,6 +106,29 @@ write the server could not confirm, such as a group selection, may still have taken effect; read the resource back before retrying. Error messages follow the [visibility rule](api-config.html#Visibility). +## Conditional requests + +Two resources carry a strong `ETag` that a write compares in `If-Match`: + +| Read | `ETag` | Conditional write | +|------|--------|-------------------| +| `GET /config/sources/{source_id}` | the source's `content_sha256` | `PUT /config/sources/{source_id}` | +| `GET /groups/{group_id}` | the configuration-wide `revision` | `PATCH /groups/{group_id}` | + +The server evaluates `If-Match` as +[RFC 9110 §13.1.1](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.1) +defines. `*` matches when the resource exists. A comma-separated list matches +when any strong tag in it equals the current one. A weak tag (`W/"…"`) never +matches. A header that does not match returns `412 stale_revision`; a header +that is not `*` or a list of entity tags returns `400 invalid_request`. + +The server checks the request in the order under +[choosing the status](#Choosing-the-status): body parsing and schema checks come +before the `412` precondition. RFC 9110 §13.2.1 evaluates preconditions before +it processes content; the contract keeps the body checks first because neither +check changes anything, so the order decides only which error a request with +both faults receives. + ## Page cursors Every paged list (`GET /nodes`, `/providers`, `/flows`, `/dns/cache`, diff --git a/source/v0.1.0/en/docs/groups.md b/source/v0.1.0/en/docs/groups.md index 139ea21..04472c6 100644 --- a/source/v0.1.0/en/docs/groups.md +++ b/source/v0.1.0/en/docs/groups.md @@ -133,7 +133,8 @@ a patch changes `check_url`, after which it resolves the new host itself. The patch target is the document `{"policy": …, "config": …}` built from the group's `policy` and `config` as `GET` returns them. The server applies the operations in order, as RFC 6902 requires, to that document; the whole patch -succeeds or nothing changes. +succeeds or nothing changes. Members an operation object does not define, such +as a `comment`, are ignored ([RFC 6902 §4](https://www.rfc-editor.org/rfc/rfc6902#section-4)). - `remove` makes the targeted property absent. Absent means the group drops its own value: the engine applies its inheritance and defaults, which in dae diff --git a/tools/check-contract.test.mjs b/tools/check-contract.test.mjs index c5faea7..e57ae57 100644 --- a/tools/check-contract.test.mjs +++ b/tools/check-contract.test.mjs @@ -697,7 +697,7 @@ test("source editing examples preserve exact bytes and use the accepted hash as assert.match(renderExample(request, "http"), /^PUT \/api\/v1\/config\/sources\/source-main HTTP\/1\.1/m); }); -test("source replacement accepts only complete text with a single hash precondition", () => { +test("source replacement accepts only complete text with an RFC 9110 If-Match precondition", () => { const request = example("replaceConfigSource:request:replacement"); request.body.content = ""; assertValid(validateExample(contract, request)); @@ -710,11 +710,17 @@ test("source replacement accepts only complete text with a single hash precondit const missing = structuredClone(request); delete missing.headers["If-Match"]; assertInvalid(validateExample(contract, missing)); - for (const value of ["*", `W/${request.headers["If-Match"]}`, '"17"', request.headers["If-Match"].slice(1, -1), - `${request.headers["If-Match"]}, ${request.headers["If-Match"]}`]) { + // Wildcards, weak tags and lists are well-formed; they fail to match with 412, not 400. + for (const value of ["*", `W/${request.headers["If-Match"]}`, '"17"', + `${request.headers["If-Match"]}, W/"17"`]) { const changed = structuredClone(request); changed.headers["If-Match"] = value; - assertInvalid(validateExample(contract, changed)); + assertValid(validateExample(contract, changed)); + } + for (const value of [request.headers["If-Match"].slice(1, -1), '"a" "b"', "*, \"17\""]) { + const changed = structuredClone(request); + changed.headers["If-Match"] = value; + assertInvalid(validateExample(contract, changed), `${value} is not If-Match syntax`); } }); @@ -1991,7 +1997,10 @@ test("group config admits dae's fixed policy, a missing interrupt option and eng assertValid(contract.validate(patch, [{ op: "copy", from: "/config/tolerance", path: "/config/idle_timeout" }])); assertValid(contract.validate(patch, [{ op: "replace", path: "/config/interrupt_connections", value: null }])); assertInvalid(contract.validate(patch, [{ op: "replace", path: "/config/interrupt_connections", value: "on" }]), "interrupt_connections is a boolean or null"); - assertInvalid(contract.validate(patch, [{ op: "remove", path: "/config/check_url", value: null }]), "remove carries no value"); + // RFC 6902 §4: members an operation does not define are ignored, not rejected. + assertValid(contract.validate(patch, [{ op: "remove", path: "/config/check_url", value: null }])); + assertValid(contract.validate(patch, [{ op: "replace", path: "/config/tolerance", value: 100, comment: "tune" }])); + assertInvalid(contract.validate(patch, [{ op: "remove", comment: "no path" }]), "an operation still needs its path"); assertInvalid(contract.validate(patch, [{ op: "replace", path: "/config/x-dae", value: {} }]), "extension members are not patch targets"); }); From 7169e52c3212d0b0158f92f86c0be21364e05a5f Mon Sep 17 00:00:00 2001 From: Zakk Date: Tue, 29 Sep 2026 07:41:29 +1000 Subject: [PATCH 2/7] feat(api): conditional group patch on a configuration resource GET /groups/{group_id} carries runtime selection and health, which change without a configuration change, so one strong ETag tied to the configuration revision cannot describe it. The group now sends no ETag. GET /groups/{group_id}/config returns {policy, config}, the document the patch already targeted, with the configuration revision as ETag. PATCH moves to the same path; operation paths (/policy, /config/