This package provides Cloudflare OAuth integration for Gadgets. It serves three purposes:
- Sign-in: when
cloudflareis in the deployment'sAUTH_GATEKEEPERSallowlist, "Continue with Cloudflare" appears on the login page. The grant reads the account email (verified by Cloudflare, via the/userAPI), which becomes the user's identity. Cloudflare sign-in also establishes the persistent billing-only connection described below. - AI Gateway billing: when a user connects Cloudflare or signs in with it, the billing scopes are
requested and the connection persists. The Workshop then reads a usable access
token from it (
getUsableAccessToken) to power the AI Gateway billing flow — reading the credit balance and routing BYOK inference through the account's default AI Gateway. - Workers Observability: gadgets can receive read-only access to logs, events, invocations, aggregate metrics, and traces either across an account or restricted to one Worker. Every result is authorized as an observation, and Worker bindings inject an immutable service filter and defensively discard foreign-service events. Distributed trace summaries are account-only because their names, timing, services, and counts describe the whole cross-service trace; a Worker binding can still retrieve its own events for a known trace ID.
Observability connections request workers-observability.read. The OAuth client must allow that
scope or Cloudflare will omit/reject it. Existing billing-only connections can add the grant when the
user first selects an observability resource. Cloudflare exposes account and Worker resource choices,
but both map to this one indivisible OAuth scope; resource bindings provide the finer capability
boundary after connection.
Workers telemetry is retained by Cloudflare for at most seven days. Queries default to the last hour,
and the Worker picker searches the full retention window. Suggested bindings are
CLOUDFLARE_OBSERVABILITY for account access and WORKER_OBSERVABILITY for one Worker.
A binding is not transferable. When a gadget with an observability binding is shared, each
collaborator is admitted only if their own connected Cloudflare account can read that resource
(addObserver checks it against their credentials, not the owner's). A collaborator without access is
refused, and a verification that fails for any other reason — a 5xx, a transport error — is also a
refusal rather than an admission.
The scope filter is prepended to every query, but a filter the provider accepted is not evidence it
applied it — three separate behaviours here return wrong-but-plausible data with no error. So events
are re-filtered on the way out, and a single foreign event is treated as proof the filter was dropped:
the provider's count is withheld (it would be a count of the whole account's matching telemetry) and
the event is logged at error. statistics is kept, because it describes what our query cost rather
than how much matched, and callers are told to read it for cost.
The re-check is deliberately not a hard failure. The events returned are filtered and therefore safe, and this provider has surprised us often enough that turning a hypothetical disclosure into a guaranteed outage would be the worse trade.
calculate() is the exception, and knowingly so: an aggregate cannot be un-mixed, so there is no
second line of defence to add. It rests entirely on the injected filter. The fix that would work —
grouping by $metadata.service and keeping this binding's own group, whose value is the correctly
scoped answer even for a median — changes limit and orderBy semantics for every caller, so it is a
follow-up rather than a footnote.
Cloudflare's telemetry/keys and telemetry/values endpoints ignore the filters array they accept:
verified against a live account, they answer for the whole account no matter what is passed. A
Worker-scoped binding therefore cannot use them — they would disclose every field name and value in
the account. Whenever a discovery call has to be constrained (a Worker binding, or any caller-supplied
filter), the answer is derived instead from a filtered telemetry/query events sample, which the
provider does filter correctly. Unconstrained account-wide discovery still uses the cheap endpoints.
Unresolved: whether the
workers-observability.readscope alone is accepted, or whether Cloudflare requires the Workers Observability Write permission to read telemetry. This has not been verified against a live token; if reads 403 on a correctly-scoped grant, that is the first thing to check.
A log's own structured fields are returned nested under source but are indexed under their bare
name: logger.warn(msg, {event: "x"}) comes back as source.event and is queried as event.
Confirmed both ways — telemetry/keys on a live account reports event/component/level and no
source.* key at all, and the Workers Logs
docs show
console.log({user_id: 123}) being filtered as user_id.
This matters because the provider accepts an unknown filter key, matches nothing, and returns an empty
page with no error. So discovery reports indexed names (observability-discovery.ts), and
observabilityFieldKey additionally accepts a source.-prefixed key as an alias wherever a caller
names a field — filters, calculations, groupBys, listValues — because copying a path out of a
listEvents result is the obvious thing to do and used to fail silently.
openid is intentionally not requested — the Cloudflare dashboard OAuth client isn't permitted
that scope; identity comes from the /user API (user-details.read).
summarizeFilter deliberately keeps filter values out of the audit trail — they are caller text.
But a provider error message can quote that same value straight back, so logging the message would
readmit through the error path exactly what the audit path excludes. CloudflareObservabilityApiError
therefore carries Cloudflare's numeric codes alongside the message: the request log names the
codes, and the message travels only to the caller who caused it.
The codes are not the discriminator, though — Cloudflare can return a message with no numeric code at all, so the error records separately whether the message is its or ours, and only ours is logged. A provider failure carrying no codes is therefore logged as a bare status, which is the fail-closed answer: the status still says what happened without quoting anyone's filter back.
/accounts is paginated and defaults to 20 per page, so a single GET silently returns a truncated
list — an account past the first page simply cannot be picked, with nothing to indicate why. The
account picker's substring match also stays client-side: name is the only documented server-side
filter and whether it matches exactly or by substring is not specified, so pushing it down would trade
a visible truncation for an invisible one.
You need a Cloudflare dashboard OAuth client (client id + secret). The dashboard OAuth endpoints and
scopes are hardcoded in src/oauth.ts, so you only configure the client id/secret and register the
redirect URI. Ensure the client's scope allowlist includes workers-observability.read when this
deployment offers Workers Observability resources.
The gatekeeper's OAuth redirect URI is:
${BASE_URL}/oauth
where BASE_URL defaults to http://localhost:8787/gatekeeper/cloudflare in dev — i.e. the full
redirect URI is:
http://localhost:8787/gatekeeper/cloudflare/oauth
Register exactly this (replace the host with your PUBLIC_BASE_URL when not running locally) as
an allowed/pre-registered redirect URL on the Cloudflare OAuth client. If it isn't registered you'll
get an invalid_request error: "the 'redirect_uri' parameter does not match any of the OAuth 2.0
Client's pre-registered redirect urls."
Create a .env file in this package's directory (packages/gatekeeper-cloudflare/.env):
CLIENT_ID=your-client-id-here
CLIENT_SECRET=your-client-secret-hereIn local dev, run-dev-server.ts will also seed these from CLOUDFLARE_OAUTH_CLIENT_ID /
CLOUDFLARE_OAUTH_CLIENT_SECRET if you'd rather set them in the root .dev.vars. A per-package
.env takes precedence and keeps the credential with the gatekeeper that uses it.
Note: The
.envfile is gitignored and should never be committed.
To offer "Continue with Cloudflare" on the login page, add cloudflare to the deployment's
AUTH_GATEKEEPERS allowlist (e.g. in the root .dev.vars):
AUTH_GATEKEEPERS=cloudflare,google,github
The order controls the order of the login buttons. For the AI Gateway billing / top-up flow, also
set ENABLE_CLOUDFLARE_LIMITS=true (see AI Gateway billing); a
user enables billing by connecting Cloudflare, which requests the billing scopes
(offline_access aig.read aig.run user-details.read account-settings.read). Connecting all
Cloudflare gadget resources also requests workers-observability.read.
- Start the application in dev mode (see the root README.md).
- On the login page, click Continue with Cloudflare.
- A pop-up opens to the Cloudflare authorization page; approve it.
- The pop-up closes and you're signed in, identified by your Cloudflare account email.
- To use AI Gateway credits, open Usage & billing in settings and Connect Cloudflare (this requests billing scopes only).
The redirect URI isn't registered on the OAuth client. Register exactly
http://localhost:8787/gatekeeper/cloudflare/oauth (or your PUBLIC_BASE_URL equivalent) — no
trailing slash, http not https for localhost.
CLIENT_ID / CLIENT_SECRET are missing. Ensure they're set (per-package .env or seeded from the
root .dev.vars), then restart the dev server.