Skip to content

Toggle a feature flag

Store feature flags as JSON in Workers KV, let any visitor read them, and let only an authenticated editor create, change, or delete them.

src/keys.bynk
commons keys
---
A flag name: 1–64 characters. Used as part of the KV key, so it is validated at
the boundary before any handler runs. A refined `String`, so it projects
structurally across the context boundary — the HTTP service mixes in this same
constraint for its path parameters.
---
type FlagKey = String where MinLength(1) and MaxLength(64)
---
The KV key for a flag. Flags share a KV namespace with anything else, so they are
key-prefixed.
---
fn keyOf(name: FlagKey) -> String {
"flag:\(name)"
}
---
Strip the `flag:` prefix (5 characters) back to the bare name — the inverse of
`keyOf`, used when listing.
---
fn nameOf(key: String) -> String {
key.slice(5, key.length())
}
src/flags.bynk
context flags
uses keys
consumes bynk.cloudflare { Kv } -- locks this unit to Cloudflare
---
A user proves identity with a JWT. `Editor` is the same user who additionally
carries an `editor` claim — authentication (401) stays distinct from
authorisation (403).
---
type UserId = String where NonEmpty
actor User { auth = Bearer(secret = "AUTH_JWT_SECRET"), identity = UserId }
actor Editor = User where hasClaim("editor")
---
The stored shape of a flag. Persisted as JSON in KV and decoded on read. (The
`FlagKey` type and the `keyOf`/`nameOf` key helpers live in `commons keys`, where
they are unit-tested.)
---
type Flag = {
enabled: Bool,
description: String,
}
service api from http {
-- Public: list the names of all known flags.
on GET("/flags") by Visitor () -> Effect[HttpResult[List[String]]] given Kv {
let keys <- Kv.list(Some("flag:"))
Ok(keys.map((k) => nameOf(k)))
}
-- Public: read one flag.
on GET("/flags/:name") by Visitor (name: FlagKey) -> Effect[HttpResult[Flag]] given Kv {
let stored <- Kv.get(keyOf(name))
match stored {
None => NotFound
Some(s) => match Json.decode[Flag](s) {
Ok(flag) => Ok(flag)
Err(_) => ServerError("stored flag is corrupt")
}
}
}
-- Editors only: create or update a flag. A non-editor token is 403, an
-- anonymous request 401 — both enforced at the boundary.
on PUT("/flags/:name") by e: Editor (name: FlagKey, body: Flag) -> Effect[HttpResult[Flag]] given Kv {
let _ <- Kv.put(keyOf(name), Json.encode(body))
Ok(body)
}
-- Editors only: remove a flag.
on DELETE("/flags/:name") by e: Editor (name: FlagKey) -> Effect[HttpResult[String]] given Kv {
let _ <- Kv.delete(keyOf(name))
NoContent
}
}
Open the full project ↗

This example reaches Workers-only shapes (storage bindings, agents, or cron), so it runs with bynk dev rather than in the browser playground. See Install to get started.

The flag name is a refined type — FlagKey = String where MinLength(1) and MaxLength(64) — defined in commons keys alongside the key helpers keyOf (which namespaces a name as flag:…) and nameOf (its inverse, stripping the five-character prefix). Because the constraint lives in a commons, it is type-checked and unit-tested without a KV binding, and the context flags mixes the same type into its path parameters.

The context flags consumes bynk.cloudflare { Kv }, which locks the unit to Cloudflare. Two actors sit over one service: User is a Bearer actor whose identity is a non-empty UserId, and Editor is User where hasClaim("editor"). Authentication and authorisation stay distinct — a missing token is a 401, a non-editor token a 403, both enforced at the boundary before any handler runs.

The service exposes four routes. GET /flags calls Kv.list(Some("flag:")) and maps each key back through nameOf; GET /flags/:name reads one entry and decodes it through Json.decode[Flag], returning NotFound for a missing key and ServerError for a corrupt value rather than a silent undefined. The two writes, PUT /flags/:name and DELETE /flags/:name, are bound by e: Editor and call Kv.put (with Json.encode) and Kv.delete. The stored Flag record is just { enabled: Bool, description: String }.