flexway
FRENTalk to an expert

Target configurations for upcoming versions

GuideOperationsSince version 1.6.0

Markdown

On this page

Operations guide: configure, deploy and observe the gateway. What the capability does, product side: product guide; calling it: developer guide.

This page shows, for each planned use case, the target shape of a complete flexway.yaml and the contract excerpt that goes with it, and the version meant to deliver it: 1.7.0 (devices and industry) and 1.8.0 (enterprise needs). Nothing below is built: a detail may still move before the version ships, and this page will follow. Where the shape of a key is not fixed yet, the example says so rather than invent it. Each example will become a file of examples/, validated like the others, with its version.

Already delivered in 1.6.0, and so described elsewhere: the native Kafka proxy (Native Kafka clients: a virtual cluster per tenant), field encryption (Field encryption), Protobuf, Avro and CBOR payloads under schema and the schema registry (Avro payloads under schema, schema registry), NATS and AMQP 1.0 (Examples: Kafka, RabbitMQ, MQTT, Avro, NATS, AMQP 1.0).

What today's gateway does with these shapes (checked with flexway validate):

The example declares Today
A new flexway.yaml key: listeners.mqtt, plans, webhooks, server.ip_deny, server.proxy_protocol, ai.budget.per_subject, ai.use_cases, ai.aliases, plan_claim, use_case_claim, trusted_clients, auth.mtls.clients[].plan Refused at load (field ... not found): the whole configuration is refused
An x-flexway-bind: "cert:<field>" binding, a webhook scheme read from a header other than Authorization Refused at load
An upcoming policy type: compress, ip-filter Accepted, never applied: flexway validate and the start warn about it (a later version - NOT applied)
An upcoming contract extension: x-flexway-device, x-flexway-sparkplug, x-flexway-use-case, x-flexway-webhook, x-flexway-coalesce Refused at load (not served yet - planned for <version>): the whole contract is refused, like any unknown x-flexway-* extension (unknown extension, with the closest one when it is obvious) or one written where it is not read (not read here) - Not built yet

Planned in 1.7

MQTT devices: X.509 or token, topic bound to the tenant and the certificate

Target of version 1.7.0 - not built yet: refused at load today (listeners.mqtt, x-flexway-bind: "cert:cn", x-flexway-device).

The gateway is an MQTT proxy in front of the client's broker: one upstream connection per device, each packet judged by the same decision as an HTTP request. Sessions, retained messages, wills and QoS 2 stay with the broker.

# flexway.yaml - the listener; no topic here, they come from the contract
version: 1
contracts:
  asyncapi: api/asyncapi.yaml
listeners:
  mqtt:
    address: ":8883"
    tls: { cert_file: /tls/tls.crt, key_file: /tls/tls.key, client_ca_file: /tls/ca.crt }   # the devices' X.509
    websocket: { path: /mqtt }
    max_packet_bytes: 262144
    connect_timeout: 5s                # a slow CONNECT is closed
upstreams:
  plant-broker:
    protocol: mqtt
    brokers: ["mqtts://broker.plant.internal:8883"]
    protocol_version: 5
auth:
  issuers:                             # for a device without a certificate: a token as the password
    - name: devices
      discovery: https://id.example.com/.well-known/openid-configuration
      audience: plant-devices
      tenant_claim: site
# api/asyncapi.yaml - the listeners' convention, the native Kafka proxy's (1.6.0):
# the extension on the operation, send = the device publishes, receive = it subscribes
channels:
  telemetry:
    address: "plant/{site}/line/{line}/{device}/telemetry"
    parameters:
      site:   { x-flexway-bind: tenant }      # the verified tenant, never the topic's
      device: { x-flexway-bind: "cert:cn" }   # the common name of the device's certificate
    messages:
      reading:
        contentType: application/x-protobuf
        payload: { schemaFormat: "application/vnd.google.protobuf;version=3", schema: { $ref: "./reading.binpb#Reading" } }
operations:
  publishTelemetry:
    action: send                       # the device publishes: writes to the channel
    channel: { $ref: "#/channels/telemetry" }
    x-flexway-upstream: plant-broker
    x-flexway-device: { qos_max: 1, validate: full }
    security: [{ deviceCert: [] }]
Packet Decision
CONNECT A verified certificate, else the password (a verified token or a key), else refused 0x87; the client id is rewritten, bound to the identity - a device never evicts another; the will is judged as a PUBLISH
PUBLISH Topic -> declared channel, bound parameters equal to the verified values (site = tenant, device = CN), scope, quota, schema when validate: full; refused with PUBACK 0x87, 0x97 (quota), 0x99 (format) in MQTT 5, closed in 3.1.1
SUBSCRIBE Each filter, wildcards included, must fit inside a declared channel once the bound parameters are set; else SUBACK 0x87
Token exp DISCONNECT 0xA0 (MQTT 5); the device comes back with a fresh token

Planned in 1.7

Sparkplug B

Target of version 1.7.0 - not built yet: refused at load today (listeners.mqtt, x-flexway-bind: "cert:cn", x-flexway-sparkplug).

flexway.yaml is that of 12.29.6. A Sparkplug B 3.0 channel takes one word: the spBv1.0/... namespace and the sparkplug_b.proto descriptor are built in.

# api/asyncapi.yaml - excerpt
channels:
  sparkplug:
    address: "spBv1.0/{group}/{type}/{edge_node}"
    x-flexway-sparkplug: true
    parameters:
      group:     { x-flexway-bind: tenant }     # the Sparkplug group is the tenant
      edge_node: { x-flexway-bind: "cert:cn" }  # a node publishes under its own name only
Element Handling
Birth and death NBIRTH/DBIRTH -> online, NDEATH (will)/DDEATH -> offline, per tenant; bdSeq checked; seen in the console
Sequences seq 0-255 checked per node, a gap counted (flexway.sparkplug.sequence.gaps), never blocking
Commands NCMD/DCMD published by an application (an HTTP x-flexway-publish gate or MQTT) under a declared write scope

Planned in 1.7

OPC UA PubSub JSON over MQTT

Target of version 1.7.0 - not built yet: refused at load today (listeners.mqtt, x-flexway-device).

No extension of its own: an OPC UA PubSub publisher sending JSON over MQTT is a device of MQTT devices, under an ordinary contract - a channel, its JSON schema, its bindings. The plant/{site}/ prefix (configured in the publisher) binds the tenant; the rest follows the topic structure OPC UA recommends (part 14).

# api/asyncapi.yaml - excerpt
channels:
  opcua-data:
    address: "plant/{site}/opcua/json/data/{publisher}/{writer_group}"
    parameters:
      site:      { x-flexway-bind: tenant }
      publisher: { x-flexway-bind: "cert:cn" }   # the publisher sends under its own name
    messages:
      network-message:
        contentType: application/json
        payload: { $ref: "./opcua-network-message.json" }   # the JSON NetworkMessage, validated
operations:
  publishOpcuaData:
    action: send
    channel: { $ref: "#/channels/opcua-data" }
    x-flexway-upstream: plant-broker
    x-flexway-device: { qos_max: 1, validate: full }
    security: [{ deviceCert: [] }]

Planned in 1.8

Plans read from a claim, a default plan

Target of version 1.8.0 - not built yet: refused at load today (plans, plan_claim).

Selling an API by plans: a caller's plan comes from a verified claim of its token, or from the plan attribute of its API key or its certificate; its limits are declared here. A caller without a plan, or whose plan is not declared, gets the default plan - never "unlimited".

# flexway.yaml
version: 1
contracts:
  openapi: ./openapi.yaml
upstreams:
  default:
    url: http://orders.internal:8080
server:
  counters: { store: "${COUNTERS_URL}" }   # required: refused at load without a shared store
auth:
  issuers:                             # the existing key is auth.issuers
    - name: corporate
      discovery: https://id.example.com/.well-known/openid-configuration
      audience: orders-api
      tenant_claim: org
      plan_claim: subscription.plan    # a dotted path to a claim of the verified token
plans:
  default: free                        # required as soon as plans: exists
  count_per: tenant                    # tenant (default) | subject
  definitions:
    free:
      requests: { day: 1000, month: 20000 }
      tokens:   { day: 50000, month: 1000000 }
      eur:      { month: 5 }
    pro:
      requests: { day: 100000, month: 2000000 }
      tokens:   { month: 50000000 }
      eur:      { month: 500 }
# api/openapi.yaml - excerpt: nothing specific to plans, the contract only says who gets in
paths:
  /orders:
    get:
      operationId: listOrders
      security: [ { corporate: [orders:read] } ]
Rule Behaviour
Unknown plan The default plan, a plan_unknown log line and the flexway.plans.unknown counter - the caller is authenticated, the gap comes from its provider
With the tenant's quotas (Setting quotas) Both apply, the first reached refuses: 429 quota_exceeded, member limit: plan
API key, certificate The plan attribute of the store entry (written by flexway api-key) or of auth.mtls.clients[]

Planned in 1.8

Inference budgets per user and per use case

Target of version 1.8.0 - not built yet: refused at load today (ai.budget.per_subject, ai.use_cases, ai.aliases, use_case_claim, x-flexway-use-case).

On top of the tenant's budget (Serving inference), a budget per user (the verified subject) and per use case. The use case comes from the contract (x-flexway-use-case), from a model alias of /v1/*, or from the claim of a trusted client - never from a caller's header. The most restrictive budget wins.

# flexway.yaml
version: 1
contracts:
  openapi: ./openapi.yaml
upstreams:
  default:
    url: http://tickets.internal:8080
auth:
  issuers:
    - name: corporate
      discovery: https://id.example.com/.well-known/openid-configuration
      audience: ai
      tenant_claim: org
      use_case_claim: ai_use_case      # believed only for the clients of trusted_clients
      trusted_clients: [batch-reviewer]
ai:
  security: [ { oidc: { scopes: [inference] } } ]
  default_backend: hosted
  backends:
    hosted:
      url: https://api.provider.example/v1
      api_key: "file:/etc/flexway/secrets/provider-key"
      default_model: gpt-4.1-mini
      external: true
      price: { input_per_mtok: 0.40, output_per_mtok: 1.60 }
  budget:
    store: "${COUNTERS_URL}"
    monthly_eur: 2000                  # tenant, unchanged
    per_subject: { monthly_eur: 20, daily_tokens: 200000 }   # each verified subject
  use_cases:
    support-bot: { monthly_eur: 300 }
    code-review: { monthly_eur: 800, daily_tokens: 5000000 }
  aliases:                             # a /v1/* model name that carries a use case
    support-assistant: { backend: hosted, model: gpt-4.1-mini, use_case: support-bot }
# api/openapi.yaml - excerpt: an operation (or a metered gRPC method) carries its use case
paths:
  /tickets/{id}/summary:
    post:
      operationId: summarizeTicket
      x-flexway-use-case: support-bot  # a use case missing from ai.use_cases: refused at load
Rule Behaviour
A call without a subject No user budget; the tenant's applies
Contract and claim The contract wins: a trusted client does not move a marked operation to another budget
Refusal 429 budget_exceeded, member limit: tenant, plan, subject or use_case (OpenAI envelope on /v1/*)
Soft cap The tenant's only; a user or use case budget is a hard cap
Consumption per user GET /usage/subjects (administration), subject hashed; never a metric dimension

Planned in 1.8

Incoming Stripe webhook and secret rotation

Target of version 1.8.0 - not built yet: refused at load today (webhooks, x-flexway-webhook, a scheme read from Stripe-Signature).

Receiving a provider's webhooks signed: the signature is the caller's identity, an alternative of security in the contract. A preset is enough for Stripe, GitHub, Slack, Shopify and SendGrid.

# flexway.yaml - secrets by reference, never in clear
version: 1
contracts:
  openapi: ./openapi.yaml
upstreams:
  default:
    url: http://billing.internal:8080
webhooks:
  secrets:
    stripe-endpoint:
      current: file:/secrets/stripe/current
      previous: file:/secrets/stripe/previous   # rotation: both verify
      tenant: acme                     # bound like an API key
  replay: { max_ids: 100000 }          # ids seen, per replica, bounded
# api/openapi.yaml - excerpt
components:
  securitySchemes:
    stripe:
      type: apiKey
      in: header
      name: Stripe-Signature
      x-flexway-webhook: { preset: stripe, secret: stripe-endpoint }
paths:
  /hooks/stripe:
    post:
      operationId: stripeEvents
      security: [{ stripe: [] }]

Rotating the secret without losing an event:

Step Action
1 The old secret moves to previous, the new one to current (two keys of the mounted Secret); hot reload (Changing the configuration without a restart) - both verify
2 The provider switches to the new secret; a signature against either one is enough
3 Once the switch is done, previous is removed
Refusal Response
Header missing or unreadable, wrong signature, timestamp out of tolerance, id already seen 401 webhook_signature_invalid, without saying which; the cause goes to the log and to flexway.webhook.verifications{outcome}
Body past the bound 413, before any verification

The body is read whole and bounded before the verification, then relayed byte for byte; the signature headers stay (the upstream may verify again), the secret never leaves.

Planned in 1.8

Generic HMAC webhook

Target of version 1.8.0 - not built yet: refused at load today (webhooks, x-flexway-webhook, a scheme read from X-Signature).

A partner without a preset: the HMAC scheme is declared in full, as a closed template.

# flexway.yaml
version: 1
contracts:
  openapi: ./openapi.yaml
upstreams:
  default:
    url: http://partners.internal:8080
webhooks:
  secrets:
    partner-hooks:
      current: file:/secrets/partner/current
      tenant: partner-a
  replay: { max_ids: 100000 }
# api/openapi.yaml - excerpt
components:
  securitySchemes:
    partner:
      type: apiKey
      in: header
      name: X-Signature
      x-flexway-webhook:
        secret: partner-hooks
        algorithm: sha256              # sha256 | sha512; sha1 refused
        signature: { header: X-Signature, prefix: "sha256=", encoding: hex }
        timestamp: { header: X-Timestamp, format: unix }
        id: { header: X-Delivery-Id }
        signed: "{timestamp}.{body}"   # closed template: {id}, {timestamp}, {body}
        tolerance: 5m
paths:
  /hooks/partner:
    post:
      operationId: partnerEvents
      security: [{ partner: [] }]

Planned in 1.8

Standard Webhooks, a shared replay memory

Target of version 1.8.0 - not built yet: refused at load today (webhooks, x-flexway-webhook, a scheme read from webhook-signature).

A sender that follows Standard Webhooks, the format of Flexway's outgoing webhooks (Publishing and consuming on a broker): HMAC-SHA256 (v1) or Ed25519 (v1a), id and timestamp signed. With several replicas, the memory of the ids seen can be shared (a declared option): without it, a replay on another replica within the tolerance window gets through.

# flexway.yaml
version: 1
contracts:
  openapi: ./openapi.yaml
upstreams:
  default:
    url: http://events.internal:8080
server:
  counters: { store: "${COUNTERS_URL}" }   # the shared memory lives in the counters' store
webhooks:
  secrets:
    platform-events:
      current: file:/secrets/platform/current   # whsec_<base64>
      previous: file:/secrets/platform/previous
      tenant: acme
  replay: { max_ids: 100000, shared: true }    # a timeout, and a refusal if the store does not answer
# api/openapi.yaml - excerpt
components:
  securitySchemes:
    platform:
      type: apiKey
      in: header
      name: webhook-signature
      x-flexway-webhook: { preset: standard-webhooks, secret: platform-events }
paths:
  /hooks/platform:
    post:
      operationId: platformEvents
      security: [{ platform: [] }]
Preset Replay
standard-webhooks, stripe, slack, sendgrid Signed timestamp: tolerance and ids seen
github, shopify Unsigned id: partial protection, said by /config

Planned in 1.8

Address lists: a global deny list and ip-filter

Target of version 1.8.0 - not built yet: refused at load today (server.ip_deny, server.proxy_protocol). On its own, an ip-filter policy is accepted with a warning and is not applied.

Two gates: server.ip_deny, a global deny list judged before routing (a threat feed costs neither routing nor cryptography); the ip-filter policy, attached to a host, a route or an operation, judged after routing and before CORS and identity.

# flexway.yaml
version: 1
contracts:
  openapi: ./openapi.yaml
upstreams:
  default:
    url: http://shop.internal:8080
server:
  trusted_proxies: [10.0.0.0/8]        # X-Forwarded-For believed from these peers only (already exists)
  proxy_protocol: { from: [10.0.5.0/24] }   # v1 and v2, from these peers only; read at start
  ip_deny:
    inline: [203.0.113.0/24]
    files: [/lists/threat-feed.txt]    # one range per line, # comment; read again at reload
policies:
  definitions:
    office-only:   { type: ip-filter, config: { allow: [198.51.100.0/24, "2001:db8::/32"] } }
    stripe-ranges: { type: ip-filter, config: { allow_files: [/lists/stripe-ips.txt] } }
  chains:
    hosts: { admin.example.com: [office-only] }
# api/openapi.yaml - excerpt: the operation level of the chain, in the contract like any policy
paths:
  /hooks/stripe:
    post:
      operationId: stripeEvents
      x-flexway-policies: [stripe-ranges]   # the ranges the provider publishes
Rule Behaviour
Client address The TCP peer; behind a peer of trusted_proxies, X-Forwarded-For read right to left up to the first untrusted hop; PROXY protocol from another peer: the connection is closed
ip-filter allow: the address must be in it; deny: refused if it is; deny wins; the most precise chain wins, !office-only removes it
Unknown route The host's chain is judged before 404 or 405: a refused caller gets 403 and does not map the routes
Refusal 403 address_denied, before any cryptography; flexway.ip.refused{level}
File unreadable at reload The previous configuration stays - never an empty list put in its place

Planned in 1.8

Negotiated compression

Target of version 1.8.0 - not built yet: today, the compress policy is accepted with a warning (a later version - NOT applied) and is not applied.

gzip, brotli and zstd, negotiated by Accept-Encoding; opt-in through a policy - without it, nothing changes.

# flexway.yaml
version: 1
contracts:
  openapi: ./openapi.yaml
upstreams:
  default:
    url: http://catalog.internal:8080
policies:
  definitions:
    compress-json:
      type: compress
      config:
        encodings: [zstd, br, gzip]    # the server's preference at equal quality
        min_bytes: 1024                # default fixed by bench M1
        types: [application/json, "application/*+json", "text/*"]   # an allow list
  chains:
    global: [compress-json]
# api/openapi.yaml - excerpt: nothing to declare; a stream is never compressed
paths:
  /products:
    get:
      operationId: listProducts
Rule Behaviour
Eligible Known length of at least min_bytes, a type of the allow list, no Content-Encoding from the upstream, a method other than HEAD; a stream (SSE, unknown length, 101) never
Headers Content-Encoding, Content-Length recomputed, Vary: Accept-Encoding on every eligible response, a strong ETag made weak
Levels Fixed by bench, one constant per algorithm: no key
Cache (Caching a read) One entry per chosen encoding, never per raw Accept-Encoding value
BREACH A response that mixes a secret and a reflected input: exclude it with a when condition

Planned in 1.8

Coalescing in-flight reads

Target of version 1.8.0 - not built yet: refused at load today (x-flexway-coalesce).

A burst of identical reads while a first one is in flight: one upstream call, the response copied to each waiter. Opt-in per operation, in the contract; the key is the cache's - tenant and subject for an operation that requires an identity. Each waiter was admitted and counted: coalescing spares the upstream, never the quota. flexway.yaml has nothing new.

# flexway.yaml - nothing specific to coalescing
version: 1
contracts:
  openapi: ./openapi.yaml
upstreams:
  default:
    url: http://catalog.internal:8080
# api/openapi.yaml - excerpt
paths:
  /catalog/products:
    get:
      operationId: listProducts
      x-flexway-cache: { ttl: 30s }    # optional: the cache first, coalescing on its misses
      x-flexway-coalesce: { max_waiters: 256, max_bytes: 1MiB }
Rule Behaviour
Refused at load A method other than GET or HEAD, a stream, WebSocket, a gRPC method, inference
Not shareable Set-Cookie, unknown length, past max_bytes: each waiter calls the upstream
Past max_waiters The call goes its own way (flexway.coalesce.requests{role="overflow"})
Upstream error Shared like a response; a timeout of the leader is not

Next steps