type(cloudflare): 27 of 33 routes become typed ops — schema, prose, MCP, CLI, SDK
Every /v1/cloudflare route was a raw fiber handler, so the published document
carried method + path + path params and nothing else: no request schema, no
query parameters, no prose, no MCP tool, no CLI command, no SDK method. 27 are
now typed ops declared on the group, which is the ONE registry entry all five
projections read.
The wire is unchanged. Every handler keeps the exact gate it had (verified
handler-by-handler: 33/33), the same statuses and messages, the same upstream
Cloudflare paths and bodies, and the same acting-org stamp. The response is
still Cloudflare's own payload relayed verbatim — cfResult marshals the raw
upstream bytes, so field order, unmodeled fields and integers past float64
survive untouched (pinned in TestRelayIsVerbatim).
SIX routes stay untyped, each because typing it would move the wire, and each
named at its registration with the reason on the handler:
POST /ai/run/* the response is often not JSON at all
(image/audio bytes under CF's own
content type) and the body is the
model's, forwarded verbatim
GET/PUT /kv/.../values/:key a KV value is opaque bytes under the
caller's own content type
POST /d1/databases/:database/query the body is forwarded to D1 VERBATIM; a
typed In drops params and batch fields
PUT /workers/scripts/:script path param `script` (the NAME) collides
with body field `script` (the SOURCE),
and zip's URL binder gives the path the
last word
POST /pages/.../deployments an unparseable body is IGNORED here (the
deploy falls back to the production
branch); a typed In answers 400
TestEveryRouteIsTypedOrNamed closes that list: a new route here is typed by
default, or it takes a deliberate edit with a written reason.
Identity: cloud.Bridge() on the group, principal.OrgFrom(ctx) for the tenant —
never an In field, which is caller-supplied. The org-admin bit lives in a
header principal.OrgFrom does not carry, the acting-org stamp is a response
header, and ?account= must NOT become an In field (zip binds an In field from
the body too, and this route has never accepted an account there), so the
plane is pinned in allowedRequestUses with those three reasons. authWrite
fails closed off the HTTP path.
Known imprecision, reported not hidden: cfResult renders as
{"type":"object"} because zip's schemaOf has no vocabulary for "any JSON" —
json.RawMessage reflects as an array of integers, which is why cfResult is a
struct at all. For the list endpoints the document therefore says object where
Cloudflare answers an array. A zip patch teaching schemaOf that
json.RawMessage means `{}` upgrades all 27 with no change here.
Baseline check: apps/cloudflare green before and after; the repo-wide suite
fails the same 12 tests in the same 5 untouched packages (functions, platform,
provisioning, storage, kmsreseal) before and after.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -79,8 +79,14 @@ type aiUsage struct {
|
||||
|
||||
// aiRun runs a Workers AI model and relays the result, metering the BYO fee + emitting
|
||||
// a gen_ai span. See the file header for the usage/o11y/payer contract.
|
||||
func aiRun(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authClient(s, c)
|
||||
//
|
||||
// NOT a typed op, for two independent reasons: the request body is whatever the
|
||||
// chosen model takes (a prompt, chat messages, a base64 audio clip) and is forwarded
|
||||
// verbatim, and the response is frequently NOT JSON — an image or audio model
|
||||
// answers bytes under Cloudflare's own content type, which a typed op cannot emit.
|
||||
func (o ops) aiRun(c *zip.Ctx) error {
|
||||
s := o.s
|
||||
cl, org, err := o.authClient(c.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -115,7 +121,7 @@ func aiRun(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
|
||||
// Only once the gate passes: resolve the account (may discover) and run.
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
acct, err := cl.resolveAccount(c.Context(), org)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+178
-69
@@ -137,64 +137,108 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
routes)
|
||||
}
|
||||
|
||||
// ops binds the service to every op on this plane. A TypedHandler is
|
||||
// func(context.Context, *In) (*Out, error) — no parameter for the service — so it
|
||||
// arrives as a RECEIVER and each op is a method value (o.zonesList), which is also
|
||||
// the only bound form cmd/zipdoc can lift prose from. The handful of routes that
|
||||
// cannot be typed (see routes) are methods on the same receiver, so there is ONE
|
||||
// way a handler here reaches the service.
|
||||
type ops struct{ s *cloud.Service[state] }
|
||||
|
||||
// zipdoc lifts the doc comment off each typed op and its In/Out fields into
|
||||
// zipdoc_gen.go, which is the ONLY way that prose reaches the published document
|
||||
// and the MCP tool list — Go drops comments at compile time. Run by `make openapi`.
|
||||
//
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// routes registers the first-class /v1/cloudflare surface. Every route runs through
|
||||
// authClient (validated-org gate + fail-closed per-org token) FIRST — reads require
|
||||
// a validated org, mutations additionally require org admin (authWrite) — so no
|
||||
// route is a softer target than another.
|
||||
//
|
||||
// Ops are declared on the GROUP, so each op's path is the group's prefix composed
|
||||
// with its leaf — the same composition the router does, and the identity every
|
||||
// projection (document, MCP tool, CLI command, SDK method) keys on.
|
||||
//
|
||||
// SIX routes are deliberately NOT typed ops, because a typed op decodes its input
|
||||
// from JSON and writes its output as JSON, and these six carry bytes that are
|
||||
// neither. Each is named where it is registered; the reason is on the handler.
|
||||
func routes(app cloud.Router, s *cloud.Service[state]) {
|
||||
g := app.Group("/v1/cloudflare")
|
||||
o := ops{s: s}
|
||||
|
||||
// Bridge FIRST: a typed op receives only a context, so the validated org
|
||||
// reaches it by being parked there — never as an In field, which is
|
||||
// caller-supplied and would be a cross-tenant read the caller asserted for
|
||||
// itself. fiber runs middleware in registration order, so this must precede
|
||||
// the leaves below; it is prefix-scoped, and nesting under Serve's own Bridge
|
||||
// is harmless (the inner one is what the handler sees).
|
||||
g.Use(cloud.Bridge())
|
||||
|
||||
// Zones + Analytics (read) — enumerate the org's zones and read a zone's traffic
|
||||
// analytics; the zone ids feed Workers routes and analytics. Zone/record
|
||||
// MANAGEMENT stays with the Hanzo DNS plane (/v1/dns); this only surfaces CF zones.
|
||||
g.Get("/zones", cloud.Handle(s, zonesList))
|
||||
g.Get("/zones/:zone", cloud.Handle(s, zoneGet))
|
||||
g.Get("/zones/:zone/analytics", cloud.Handle(s, zoneAnalytics))
|
||||
g.Post("/zones/:zone/purge", cloud.Handle(s, zonePurge))
|
||||
zip.Get(g, "/zones", o.zonesList)
|
||||
zip.Get(g, "/zones/:zone", o.zoneGet)
|
||||
zip.Get(g, "/zones/:zone/analytics", o.zoneAnalytics)
|
||||
zip.Post(g, "/zones/:zone/purge", o.zonePurge)
|
||||
|
||||
// Pages — account-scoped.
|
||||
g.Get("/pages/projects", cloud.Handle(s, pagesList))
|
||||
g.Post("/pages/projects", cloud.Handle(s, pagesCreate))
|
||||
g.Get("/pages/projects/:project", cloud.Handle(s, pagesGet))
|
||||
g.Delete("/pages/projects/:project", cloud.Handle(s, pagesDelete))
|
||||
g.Post("/pages/projects/:project/deployments", cloud.Handle(s, pagesDeploy))
|
||||
g.Post("/pages/projects/:project/domains", cloud.Handle(s, pagesDomainAdd))
|
||||
g.Delete("/pages/projects/:project/domains/:domain", cloud.Handle(s, pagesDomainDelete))
|
||||
zip.Get(g, "/pages/projects", o.pagesList)
|
||||
zip.Post(g, "/pages/projects", o.pagesCreate)
|
||||
zip.Get(g, "/pages/projects/:project", o.pagesGet)
|
||||
zip.Delete(g, "/pages/projects/:project", o.pagesDelete)
|
||||
// UNTYPED: a malformed deploy body is IGNORED here (the deploy falls back to the
|
||||
// project's production branch); a typed In answers 400 instead, which is a
|
||||
// different contract. See pagesDeploy.
|
||||
g.Post("/pages/projects/:project/deployments", o.pagesDeploy)
|
||||
zip.Post(g, "/pages/projects/:project/domains", o.pagesDomainAdd)
|
||||
zip.Delete(g, "/pages/projects/:project/domains/:domain", o.pagesDomainDelete)
|
||||
|
||||
// Workers — scripts + workers.dev subdomain are account-scoped; routes are
|
||||
// zone-scoped.
|
||||
g.Get("/workers/scripts", cloud.Handle(s, workersScriptList))
|
||||
g.Put("/workers/scripts/:script", cloud.Handle(s, workersScriptPut))
|
||||
g.Delete("/workers/scripts/:script", cloud.Handle(s, workersScriptDelete))
|
||||
g.Post("/workers/scripts/:script/subdomain", cloud.Handle(s, workersScriptSubdomainSet))
|
||||
g.Get("/workers/subdomain", cloud.Handle(s, workersSubdomainGet))
|
||||
g.Get("/workers/zones/:zone/routes", cloud.Handle(s, workersRouteList))
|
||||
g.Post("/workers/zones/:zone/routes", cloud.Handle(s, workersRouteCreate))
|
||||
g.Delete("/workers/zones/:zone/routes/:route", cloud.Handle(s, workersRouteDelete))
|
||||
zip.Get(g, "/workers/scripts", o.workersScriptList)
|
||||
// UNTYPED: the path param `script` (the script NAME) and the body field `script`
|
||||
// (the module SOURCE) share a name, and zip's URL binder gives the path the last
|
||||
// word — a typed In would overwrite the source with the name. See workersScriptPut.
|
||||
g.Put("/workers/scripts/:script", o.workersScriptPut)
|
||||
zip.Delete(g, "/workers/scripts/:script", o.workersScriptDelete)
|
||||
zip.Post(g, "/workers/scripts/:script/subdomain", o.workersScriptSubdomainSet)
|
||||
zip.Get(g, "/workers/subdomain", o.workersSubdomainGet)
|
||||
zip.Get(g, "/workers/zones/:zone/routes", o.workersRouteList)
|
||||
zip.Post(g, "/workers/zones/:zone/routes", o.workersRouteCreate)
|
||||
zip.Delete(g, "/workers/zones/:zone/routes/:route", o.workersRouteDelete)
|
||||
|
||||
// Workers AI (inference) — run a CF-hosted model with the org's own token. The
|
||||
// model rides a wildcard: CF model ids look like @cf/meta/llama-3-8b-instruct.
|
||||
// Metered through the unified AI spine + emitted to the one gen_ai span plane.
|
||||
g.Post("/ai/run/*", cloud.Handle(s, aiRun))
|
||||
// UNTYPED: the request body is forwarded to the model verbatim and the response
|
||||
// may be image or audio bytes under Cloudflare's own content type. See aiRun.
|
||||
g.Post("/ai/run/*", o.aiRun)
|
||||
|
||||
// R2 — account-scoped buckets.
|
||||
g.Get("/r2/buckets", cloud.Handle(s, r2BucketList))
|
||||
g.Post("/r2/buckets", cloud.Handle(s, r2BucketCreate))
|
||||
g.Delete("/r2/buckets/:bucket", cloud.Handle(s, r2BucketDelete))
|
||||
zip.Get(g, "/r2/buckets", o.r2BucketList)
|
||||
zip.Post(g, "/r2/buckets", o.r2BucketCreate)
|
||||
zip.Delete(g, "/r2/buckets/:bucket", o.r2BucketDelete)
|
||||
|
||||
// KV — namespaces and a namespace's key values.
|
||||
g.Get("/kv/namespaces", cloud.Handle(s, kvNamespaceList))
|
||||
g.Post("/kv/namespaces", cloud.Handle(s, kvNamespaceCreate))
|
||||
g.Delete("/kv/namespaces/:namespace", cloud.Handle(s, kvNamespaceDelete))
|
||||
g.Get("/kv/namespaces/:namespace/values/:key", cloud.Handle(s, kvValueGet))
|
||||
g.Put("/kv/namespaces/:namespace/values/:key", cloud.Handle(s, kvValuePut))
|
||||
g.Delete("/kv/namespaces/:namespace/values/:key", cloud.Handle(s, kvValueDelete))
|
||||
zip.Get(g, "/kv/namespaces", o.kvNamespaceList)
|
||||
zip.Post(g, "/kv/namespaces", o.kvNamespaceCreate)
|
||||
zip.Delete(g, "/kv/namespaces/:namespace", o.kvNamespaceDelete)
|
||||
// UNTYPED (both): a KV value is opaque bytes under the caller's own content type
|
||||
// — the GET relays it raw, the PUT forwards the request body raw. See kvValueGet
|
||||
// and kvValuePut.
|
||||
g.Get("/kv/namespaces/:namespace/values/:key", o.kvValueGet)
|
||||
g.Put("/kv/namespaces/:namespace/values/:key", o.kvValuePut)
|
||||
zip.Delete(g, "/kv/namespaces/:namespace/values/:key", o.kvValueDelete)
|
||||
|
||||
// D1 — databases and a query against one.
|
||||
g.Get("/d1/databases", cloud.Handle(s, d1DatabaseList))
|
||||
g.Post("/d1/databases", cloud.Handle(s, d1DatabaseCreate))
|
||||
g.Delete("/d1/databases/:database", cloud.Handle(s, d1DatabaseDelete))
|
||||
g.Post("/d1/databases/:database/query", cloud.Handle(s, d1Query))
|
||||
zip.Get(g, "/d1/databases", o.d1DatabaseList)
|
||||
zip.Post(g, "/d1/databases", o.d1DatabaseCreate)
|
||||
zip.Delete(g, "/d1/databases/:database", o.d1DatabaseDelete)
|
||||
// UNTYPED: the query body is forwarded to D1 VERBATIM so params and batch fields
|
||||
// survive; a typed In would drop every field it does not model. See d1Query.
|
||||
g.Post("/d1/databases/:database/query", o.d1Query)
|
||||
}
|
||||
|
||||
// ── client (the cfDo shape, reused verbatim from hanzodns) ──────────────────────
|
||||
@@ -328,10 +372,40 @@ func (cl *client) exec(ctx context.Context, method, path, contentType string, bo
|
||||
return nil
|
||||
}
|
||||
|
||||
// pass runs a Cloudflare call and relays its result to the caller VERBATIM (as raw
|
||||
// JSON), so the upstream shape reaches the platform without field loss — the ONE
|
||||
// response path for every wired handler. An empty result (e.g. a 204 delete) becomes
|
||||
// {"success":true}.
|
||||
// emptyResult is what a call whose envelope carried no `result` answers — a CF
|
||||
// delete that reports only success. ONE literal, read by both response paths
|
||||
// (cfResult below and writeResult), so they cannot drift.
|
||||
const emptyResult = `{"success":true}`
|
||||
|
||||
// cfResult is Cloudflare's own response payload, relayed to the caller VERBATIM so
|
||||
// the upstream shape reaches the platform without field loss. It is opaque BY
|
||||
// CONSTRUCTION: this plane proxies Cloudflare and deliberately does not model
|
||||
// Cloudflare's response shapes, so its properties are Cloudflare's, not ours — see
|
||||
// the Cloudflare API v4 reference for the endpoint behind each op. An envelope that
|
||||
// carried no result relays {"success":true}.
|
||||
type cfResult struct{ raw json.RawMessage }
|
||||
|
||||
// MarshalJSON emits the upstream payload as-is, which is what makes cfResult a
|
||||
// relay rather than a model.
|
||||
func (r cfResult) MarshalJSON() ([]byte, error) {
|
||||
if len(r.raw) == 0 {
|
||||
return []byte(emptyResult), nil
|
||||
}
|
||||
return r.raw, nil
|
||||
}
|
||||
|
||||
// relay is the ONE response path for a typed op: run the Cloudflare call and hand
|
||||
// back its result for verbatim relay, mapping a failure to a recognizable status.
|
||||
func (cl *client) relay(ctx context.Context, method, path string, body any) (*cfResult, error) {
|
||||
var out json.RawMessage
|
||||
if err := cl.cfDo(ctx, method, path, body, &out); err != nil {
|
||||
return nil, cfErr(err)
|
||||
}
|
||||
return &cfResult{raw: out}, nil
|
||||
}
|
||||
|
||||
// pass is relay for a route that cannot be a typed op (d1Query): it writes the
|
||||
// upstream result to the response itself, byte for byte.
|
||||
func (cl *client) pass(c *zip.Ctx, method, path string, body any) error {
|
||||
var out json.RawMessage
|
||||
if err := cl.cfDo(c.Context(), method, path, body, &out); err != nil {
|
||||
@@ -363,20 +437,27 @@ const actingOrgHeader = "X-Hanzo-Org"
|
||||
// Cloudflare client bound to THAT org's KMS-sealed token (503 if the org has not
|
||||
// connected Cloudflare or KMS is down). On success it stamps actingOrgHeader with the
|
||||
// served org. The token detail is logged token-free and never surfaced to the client.
|
||||
func authClient(s *cloud.Service[state], c *zip.Ctx) (*client, string, error) {
|
||||
org, ok := principal.Org(c)
|
||||
//
|
||||
// The org comes from the context — principal.OrgFrom, which IS principal.Org's
|
||||
// answer, parked there by cloud.Bridge — never from an In field: an In field is
|
||||
// caller-supplied, so a tenant key read from one is a cross-tenant read the caller
|
||||
// asserted for itself.
|
||||
func (o ops) authClient(ctx context.Context) (*client, string, error) {
|
||||
org, ok := principal.OrgFrom(ctx)
|
||||
if !ok {
|
||||
return nil, "", zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
tok, err := tokenFor(c.Context(), org, providerCloudflare, secretAPIToken)
|
||||
tok, err := tokenFor(ctx, org, providerCloudflare, secretAPIToken)
|
||||
if err != nil || len(bytes.TrimSpace(tok)) == 0 {
|
||||
// err is custody-authored and token-free (not-connected / invalid-org /
|
||||
// KMS-down). Log the reason, tell the client only that CF is unavailable.
|
||||
s.Log.Warn("cloudflare token unavailable", "org", org, "err", err)
|
||||
o.s.Log.Warn("cloudflare token unavailable", "org", org, "err", err)
|
||||
return nil, org, zip.Errorf(http.StatusServiceUnavailable, "cloudflare is not connected for this org")
|
||||
}
|
||||
// Stamp the org actually served so a per-org caller can prove no tenant comingling.
|
||||
c.SetHeader(actingOrgHeader, org)
|
||||
if c, ok := cloud.Request(ctx); ok {
|
||||
c.SetHeader(actingOrgHeader, org)
|
||||
}
|
||||
return &client{token: string(bytes.TrimSpace(tok)), base: cfAPIBase()}, org, nil
|
||||
}
|
||||
|
||||
@@ -387,33 +468,38 @@ func authClient(s *cloud.Service[state], c *zip.Ctx) (*client, string, error) {
|
||||
// a Pages project DELETE is production destruction) must match connecting it. The admin
|
||||
// check is FIRST, so a non-admin is refused before any KMS token read. Reads stay
|
||||
// validated-org-only via authClient — org members may look, only org admins may change.
|
||||
func authWrite(s *cloud.Service[state], c *zip.Ctx) (*client, string, error) {
|
||||
if !principal.IsOrgAdmin(c) {
|
||||
//
|
||||
// Org-admin-ness lives in a header (X-User-IsOrgAdmin) that principal.OrgFrom does
|
||||
// not carry, so this one predicate needs the REQUEST. It fails closed off the HTTP
|
||||
// path — no request, no attested caller, no mutation.
|
||||
func (o ops) authWrite(ctx context.Context) (*client, string, error) {
|
||||
c, ok := cloud.Request(ctx)
|
||||
if !ok || !principal.IsOrgAdmin(c) {
|
||||
return nil, "", zip.ErrForbidden("this action requires org admin")
|
||||
}
|
||||
return authClient(s, c)
|
||||
return o.authClient(ctx)
|
||||
}
|
||||
|
||||
// acctClient resolves BOTH the caller-org client and its account id — the
|
||||
// account-scoped READ preamble every Pages/Workers/Workers-AI/R2/KV/D1 handler shares,
|
||||
// so the auth+account dance is written once. Zone-scoped handlers (workers routes,
|
||||
// zones, analytics) need no account and use authClient directly.
|
||||
func acctClient(s *cloud.Service[state], c *zip.Ctx) (*client, string, error) {
|
||||
cl, org, err := authClient(s, c)
|
||||
func (o ops) acctClient(ctx context.Context) (*client, string, error) {
|
||||
cl, org, err := o.authClient(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
acct, err := cl.resolveAccount(ctx, org)
|
||||
return cl, acct, err
|
||||
}
|
||||
|
||||
// acctWrite is acctClient for a mutation: org-admin FIRST (authWrite), then account.
|
||||
func acctWrite(s *cloud.Service[state], c *zip.Ctx) (*client, string, error) {
|
||||
cl, org, err := authWrite(s, c)
|
||||
func (o ops) acctWrite(ctx context.Context) (*client, string, error) {
|
||||
cl, org, err := o.authWrite(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
acct, err := cl.resolveAccount(ctx, org)
|
||||
return cl, acct, err
|
||||
}
|
||||
|
||||
@@ -424,12 +510,19 @@ func acctWrite(s *cloud.Service[state], c *zip.Ctx) (*client, string, error) {
|
||||
// multi-account token; (3) only if none is stored, discover it live from the token's
|
||||
// own /accounts. Every candidate is validated 32-hex so it can never inject path
|
||||
// structure. Fails closed (400) when nothing yields a usable account.
|
||||
func (cl *client) resolveAccount(ctx context.Context, org string, c *zip.Ctx) (string, error) {
|
||||
if a := strings.TrimSpace(c.Query("account")); a != "" {
|
||||
if !idRE.MatchString(a) {
|
||||
return "", zip.ErrBadRequest("account must be a 32-character hex id")
|
||||
//
|
||||
// The ?account= override is read off the REQUEST rather than modeled as an In
|
||||
// field, because zip binds an In field from the body as well as the URL: modeling
|
||||
// it would let a POST body name the account, which is not what this route accepts
|
||||
// today.
|
||||
func (cl *client) resolveAccount(ctx context.Context, org string) (string, error) {
|
||||
if c, ok := cloud.Request(ctx); ok {
|
||||
if a := strings.TrimSpace(c.Query("account")); a != "" {
|
||||
if !idRE.MatchString(a) {
|
||||
return "", zip.ErrBadRequest("account must be a 32-character hex id")
|
||||
}
|
||||
return url.PathEscape(a), nil
|
||||
}
|
||||
return url.PathEscape(a), nil
|
||||
}
|
||||
if conn, ok := connectionFor(org, providerCloudflare); ok {
|
||||
if id := strings.TrimSpace(conn.ExternalID); idRE.MatchString(id) {
|
||||
@@ -450,17 +543,23 @@ func (cl *client) resolveAccount(ctx context.Context, org string, c *zip.Ctx) (s
|
||||
return "", zip.ErrBadRequest("no cloudflare account is resolvable for this token; pass ?account=<id>")
|
||||
}
|
||||
|
||||
// pathSeg reads a route param, rejects anything not matching re (so it can never
|
||||
// smuggle path structure into the upstream Cloudflare URL), and returns the
|
||||
// url.PathEscape'd value ready to concatenate into a CF path.
|
||||
func pathSeg(c *zip.Ctx, name string, re *regexp.Regexp) (string, error) {
|
||||
v := strings.TrimSpace(c.Param(name))
|
||||
// seg validates one caller-supplied path value against re (so it can never smuggle
|
||||
// path structure into the upstream Cloudflare URL) and returns it url.PathEscape'd,
|
||||
// ready to concatenate into a CF path. It is the ONE gate for every name/id segment
|
||||
// this plane forwards, whether the value arrived on a typed In or off the request.
|
||||
func seg(name, v string, re *regexp.Regexp) (string, error) {
|
||||
v = strings.TrimSpace(v)
|
||||
if !re.MatchString(v) {
|
||||
return "", zip.ErrBadRequest(name + " is invalid")
|
||||
}
|
||||
return url.PathEscape(v), nil
|
||||
}
|
||||
|
||||
// pathSeg is seg over a route param, for the routes that are not typed ops.
|
||||
func pathSeg(c *zip.Ctx, name string, re *regexp.Regexp) (string, error) {
|
||||
return seg(name, c.Param(name), re)
|
||||
}
|
||||
|
||||
// cfErr maps a Cloudflare call failure to a client-facing HTTP error, propagating a
|
||||
// recognizable upstream status (404/400/403/409) so a proxied not-found is not
|
||||
// mis-reported as a 502, and defaulting to 502 Bad Gateway otherwise. The message is
|
||||
@@ -499,16 +598,16 @@ func (cl *client) getRaw(ctx context.Context, path string) ([]byte, string, erro
|
||||
return data, ct, nil
|
||||
}
|
||||
|
||||
// query builds a "?..."-encoded upstream query string from an ALLOWLISTED set of
|
||||
// inbound query keys, so a read handler can forward pagination/window params
|
||||
// (page, per_page, since, until, …) without opening arbitrary passthrough. Values
|
||||
// are url.Values-escaped and the upstream HOST + PATH are fixed by the caller, so a
|
||||
// forward builds a "?..."-encoded upstream query string from an ALLOWLISTED set of
|
||||
// caller values, so a read op can forward pagination/window params (page, per_page,
|
||||
// since, until, …) without opening arbitrary passthrough. Values are
|
||||
// url.Values-escaped and the upstream HOST + PATH are fixed by the caller, so a
|
||||
// hostile value stays a query value — it can inject neither path structure nor a
|
||||
// different host (no SSRF). Overlong values (>256 bytes) are dropped.
|
||||
func query(c *zip.Ctx, keys ...string) string {
|
||||
// different host (no SSRF). Empty and overlong (>256 bytes) values are dropped.
|
||||
func forward(vals map[string]string) string {
|
||||
q := url.Values{}
|
||||
for _, k := range keys {
|
||||
if v := strings.TrimSpace(c.Query(k)); v != "" && len(v) <= 256 {
|
||||
for k, v := range vals {
|
||||
if v = strings.TrimSpace(v); v != "" && len(v) <= 256 {
|
||||
q.Set(k, v)
|
||||
}
|
||||
}
|
||||
@@ -517,3 +616,13 @@ func query(c *zip.Ctx, keys ...string) string {
|
||||
}
|
||||
return "?" + q.Encode()
|
||||
}
|
||||
|
||||
// query is forward over inbound request query keys, for the routes that are not
|
||||
// typed ops.
|
||||
func query(c *zip.Ctx, keys ...string) string {
|
||||
vals := make(map[string]string, len(keys))
|
||||
for _, k := range keys {
|
||||
vals[k] = c.Query(k)
|
||||
}
|
||||
return forward(vals)
|
||||
}
|
||||
|
||||
+58
-25
@@ -6,60 +6,93 @@ package cloudflare
|
||||
// D1 query can INSERT/UPDATE/DROP, so it takes the write gate, not the read gate.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// d1Path is the upstream account-scoped D1 base (singular "database", per the CF API).
|
||||
func d1Path(acct string) string { return "/accounts/" + acct + "/d1/database" }
|
||||
|
||||
func d1DatabaseList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, d1Path(acct)+query(c, "page", "per_page", "name"), nil)
|
||||
// databasesIn pages and filters the database list. Every field is optional and
|
||||
// rides the query string; each is forwarded to Cloudflare under the same name.
|
||||
type databasesIn struct {
|
||||
// Page is the 1-based page of databases to return.
|
||||
Page string `json:"page"`
|
||||
// PerPage is how many databases one page holds.
|
||||
PerPage string `json:"per_page"`
|
||||
// Name filters to the database with this name.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func d1DatabaseCreate(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// D1DatabaseList lists the D1 databases on the org's Cloudflare account. Any org
|
||||
// member may read.
|
||||
func (o ops) d1DatabaseList(ctx context.Context, in *databasesIn) (*cfResult, error) {
|
||||
cl, acct, err := o.acctClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
q := forward(map[string]string{"page": in.Page, "per_page": in.PerPage, "name": in.Name})
|
||||
return cl.relay(ctx, http.MethodGet, d1Path(acct)+q, nil)
|
||||
}
|
||||
|
||||
// databaseCreateIn names a new D1 database.
|
||||
type databaseCreateIn struct {
|
||||
// Name is the database name to create.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// D1DatabaseCreate creates a D1 database on the org's Cloudflare account.
|
||||
// Requires org admin.
|
||||
//
|
||||
// Example: {"name": "orders"}
|
||||
func (o ops) d1DatabaseCreate(ctx context.Context, in *databaseCreateIn) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if !nameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("database name is invalid")
|
||||
return nil, zip.ErrBadRequest("database name is invalid")
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, d1Path(acct), map[string]string{"name": name})
|
||||
return cl.relay(ctx, http.MethodPost, d1Path(acct), map[string]string{"name": name})
|
||||
}
|
||||
|
||||
func d1DatabaseDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// databaseRef addresses one D1 database, from the path.
|
||||
type databaseRef struct {
|
||||
// Database is the Cloudflare D1 database id or name.
|
||||
Database string `json:"database"`
|
||||
}
|
||||
|
||||
// D1DatabaseDelete deletes a D1 database and everything stored in it. Requires
|
||||
// org admin.
|
||||
//
|
||||
// Example: {"database": "orders"}
|
||||
func (o ops) d1DatabaseDelete(ctx context.Context, in *databaseRef) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
db, err := pathSeg(c, "database", nameRE)
|
||||
db, err := seg("database", in.Database, nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, d1Path(acct)+"/"+db, nil)
|
||||
return cl.relay(ctx, http.MethodDelete, d1Path(acct)+"/"+db, nil)
|
||||
}
|
||||
|
||||
// d1Query runs a SQL statement against a database. The body ({sql, params}) is
|
||||
// validated for a non-empty sql then forwarded VERBATIM (preserving params and any
|
||||
// batch fields), so the full CF query shape reaches D1 without field loss.
|
||||
func d1Query(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
//
|
||||
// NOT a typed op: that verbatim forward is the point. A typed In decodes the body
|
||||
// into a Go struct and re-encodes it, which drops every field the struct does not
|
||||
// model — starting with params, which is where the query's bound values live.
|
||||
func (o ops) d1Query(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+91
-37
@@ -6,58 +6,96 @@ package cloudflare
|
||||
// A value is relayed RAW (getRaw) since a stored value is not the CF JSON envelope.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func kvNamespaceList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/storage/kv/namespaces"+query(c, "page", "per_page", "order", "direction"), nil)
|
||||
// namespacesIn pages and sorts the namespace list. Every field is optional and
|
||||
// rides the query string; each is forwarded to Cloudflare under the same name.
|
||||
type namespacesIn struct {
|
||||
// Page is the 1-based page of namespaces to return.
|
||||
Page string `json:"page"`
|
||||
// PerPage is how many namespaces one page holds.
|
||||
PerPage string `json:"per_page"`
|
||||
// Order names the field to sort by, and Direction sorts asc or desc.
|
||||
Order string `json:"order"`
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
|
||||
func kvNamespaceCreate(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// KVNamespaceList lists the Workers KV namespaces on the org's Cloudflare
|
||||
// account. Any org member may read.
|
||||
func (o ops) kvNamespaceList(ctx context.Context, in *namespacesIn) (*cfResult, error) {
|
||||
cl, acct, err := o.acctClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
var in struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
q := forward(map[string]string{
|
||||
"page": in.Page, "per_page": in.PerPage, "order": in.Order, "direction": in.Direction,
|
||||
})
|
||||
return cl.relay(ctx, http.MethodGet, "/accounts/"+acct+"/storage/kv/namespaces"+q, nil)
|
||||
}
|
||||
|
||||
// namespaceCreateIn titles a new KV namespace.
|
||||
type namespaceCreateIn struct {
|
||||
// Title is the namespace's display title. Cloudflare mints the id.
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// KVNamespaceCreate creates a Workers KV namespace on the org's Cloudflare
|
||||
// account. Requires org admin. Cloudflare mints the namespace id the value routes
|
||||
// address.
|
||||
//
|
||||
// Example: {"title": "sessions"}
|
||||
func (o ops) kvNamespaceCreate(ctx context.Context, in *namespaceCreateIn) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(in.Title)
|
||||
if title == "" {
|
||||
return zip.ErrBadRequest("namespace title is required")
|
||||
return nil, zip.ErrBadRequest("namespace title is required")
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/storage/kv/namespaces", map[string]string{"title": title})
|
||||
return cl.relay(ctx, http.MethodPost, "/accounts/"+acct+"/storage/kv/namespaces",
|
||||
map[string]string{"title": title})
|
||||
}
|
||||
|
||||
func kvNamespaceDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// namespaceRef addresses one KV namespace by id, from the path.
|
||||
type namespaceRef struct {
|
||||
// Namespace is the Cloudflare KV namespace id.
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
|
||||
// KVNamespaceDelete deletes a Workers KV namespace and every key in it. Requires
|
||||
// org admin.
|
||||
//
|
||||
// Example: {"namespace": "0123456789abcdef0123456789abcdef"}
|
||||
func (o ops) kvNamespaceDelete(ctx context.Context, in *namespaceRef) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
ns, err := pathSeg(c, "namespace", nameRE)
|
||||
ns, err := seg("namespace", in.Namespace, nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/storage/kv/namespaces/"+ns, nil)
|
||||
return cl.relay(ctx, http.MethodDelete, "/accounts/"+acct+"/storage/kv/namespaces/"+ns, nil)
|
||||
}
|
||||
|
||||
// kvValueGet relays a namespace key's raw value (getRaw — a stored value is bytes,
|
||||
// not the CF envelope), with its content type. A missing key is Cloudflare's own 404.
|
||||
func kvValueGet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctClient(s, c)
|
||||
//
|
||||
// NOT a typed op: a KV value is opaque bytes under whatever content type it was
|
||||
// written with, and a typed op answers JSON. Typing it would re-encode a stored
|
||||
// value into a JSON document.
|
||||
func (o ops) kvValueGet(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cl, acct, err := o.acctClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -69,7 +107,7 @@ func kvValueGet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, ctype, err := cl.getRaw(c.Context(), "/accounts/"+acct+"/storage/kv/namespaces/"+ns+"/values/"+key)
|
||||
data, ctype, err := cl.getRaw(ctx, "/accounts/"+acct+"/storage/kv/namespaces/"+ns+"/values/"+key)
|
||||
if err != nil {
|
||||
return cfErr(err)
|
||||
}
|
||||
@@ -79,8 +117,12 @@ func kvValueGet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
|
||||
// kvValuePut writes a key's value: the request body IS the value (any content type),
|
||||
// forwarded verbatim; optional expiration params ride the query. Mutation → org admin.
|
||||
func kvValuePut(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
//
|
||||
// NOT a typed op: the request body IS the stored value, under the caller's own
|
||||
// content type. A typed In would parse it as JSON and refuse everything else.
|
||||
func (o ops) kvValuePut(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -98,26 +140,38 @@ func kvValuePut(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
var out json.RawMessage
|
||||
path := "/accounts/" + acct + "/storage/kv/namespaces/" + ns + "/values/" + key + query(c, "expiration", "expiration_ttl")
|
||||
if err := cl.cfUpload(c.Context(), http.MethodPut, path, ctype, c.Body(), &out); err != nil {
|
||||
if err := cl.cfUpload(ctx, http.MethodPut, path, ctype, c.Body(), &out); err != nil {
|
||||
return cfErr(err)
|
||||
}
|
||||
return writeResult(c, out)
|
||||
}
|
||||
|
||||
func kvValueDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// valueRef addresses one key in one KV namespace, both from the path.
|
||||
type valueRef struct {
|
||||
// Namespace is the Cloudflare KV namespace id.
|
||||
Namespace string `json:"namespace"`
|
||||
// Key is the key within that namespace. KV keys are broad (up to 512 bytes),
|
||||
// so this one is escaped rather than charset-restricted.
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// KVValueDelete removes one key from a Workers KV namespace. Requires org admin.
|
||||
//
|
||||
// Example: {"namespace": "0123456789abcdef0123456789abcdef", "key": "session/abc"}
|
||||
func (o ops) kvValueDelete(ctx context.Context, in *valueRef) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
ns, err := pathSeg(c, "namespace", nameRE)
|
||||
ns, err := seg("namespace", in.Namespace, nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
key, err := kvKeySeg(c.Param("key"))
|
||||
key, err := kvKeySeg(in.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/storage/kv/namespaces/"+ns+"/values/"+key, nil)
|
||||
return cl.relay(ctx, http.MethodDelete, "/accounts/"+acct+"/storage/kv/namespaces/"+ns+"/values/"+key, nil)
|
||||
}
|
||||
|
||||
// kvKeySeg validates + url-escapes a KV key for the value path segment. KV keys are
|
||||
|
||||
+97
-52
@@ -8,11 +8,11 @@ package cloudflare
|
||||
// responses relay verbatim (no field loss).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -71,55 +71,81 @@ type PagesProjectCreate struct {
|
||||
|
||||
// ── handlers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func pagesList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/pages/projects", nil)
|
||||
// noInput is the In of an op addressed entirely by the caller's principal: it takes
|
||||
// nothing off the wire.
|
||||
type noInput struct{}
|
||||
|
||||
// projectRef addresses one Pages project by name, from the path.
|
||||
type projectRef struct {
|
||||
// Project is the Pages project name.
|
||||
Project string `json:"project"`
|
||||
}
|
||||
|
||||
func pagesGet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctClient(s, c)
|
||||
// PagesList lists the org's Cloudflare Pages projects. Any org member may read.
|
||||
func (o ops) pagesList(ctx context.Context, _ *noInput) (*cfResult, error) {
|
||||
cl, acct, err := o.acctClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
proj, err := pathSeg(c, "project", nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/pages/projects/"+proj, nil)
|
||||
return cl.relay(ctx, http.MethodGet, "/accounts/"+acct+"/pages/projects", nil)
|
||||
}
|
||||
|
||||
func pagesCreate(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// PagesGet reads one Cloudflare Pages project — its build config, deployment
|
||||
// configs and latest deployment. Any org member may read.
|
||||
//
|
||||
// Example: {"project": "marketing-site"}
|
||||
func (o ops) pagesGet(ctx context.Context, in *projectRef) (*cfResult, error) {
|
||||
cl, acct, err := o.acctClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
var in PagesProjectCreate
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
proj, err := seg("project", in.Project, nameRE)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cl.relay(ctx, http.MethodGet, "/accounts/"+acct+"/pages/projects/"+proj, nil)
|
||||
}
|
||||
|
||||
// PagesCreate creates a Cloudflare Pages project on the org's account. Requires
|
||||
// org admin. Only the modeled fields reach Cloudflare, so an unmodeled key in the
|
||||
// request is dropped rather than forwarded.
|
||||
//
|
||||
// Example: {"name": "marketing-site", "production_branch": "main"}
|
||||
func (o ops) pagesCreate(ctx context.Context, in *PagesProjectCreate) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !nameRE.MatchString(strings.TrimSpace(in.Name)) {
|
||||
return zip.ErrBadRequest("project name is invalid")
|
||||
return nil, zip.ErrBadRequest("project name is invalid")
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/pages/projects", in)
|
||||
return cl.relay(ctx, http.MethodPost, "/accounts/"+acct+"/pages/projects", *in)
|
||||
}
|
||||
|
||||
func pagesDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// PagesDelete deletes a Cloudflare Pages project, and with it every deployment it
|
||||
// has ever made. Requires org admin.
|
||||
//
|
||||
// Example: {"project": "marketing-site"}
|
||||
func (o ops) pagesDelete(ctx context.Context, in *projectRef) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
proj, err := pathSeg(c, "project", nameRE)
|
||||
proj, err := seg("project", in.Project, nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/pages/projects/"+proj, nil)
|
||||
return cl.relay(ctx, http.MethodDelete, "/accounts/"+acct+"/pages/projects/"+proj, nil)
|
||||
}
|
||||
|
||||
func pagesDeploy(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// pagesDeploy triggers a new Pages deployment. Requires org admin.
|
||||
//
|
||||
// NOT a typed op: a body this handler cannot parse is IGNORED — the deployment
|
||||
// falls back to the project's production branch — where a typed In answers 400.
|
||||
// Those are different contracts, and typing it would change what the route accepts.
|
||||
func (o ops) pagesDeploy(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -140,40 +166,59 @@ func pagesDeploy(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/pages/projects/"+proj+"/deployments", body)
|
||||
}
|
||||
|
||||
func pagesDomainAdd(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// domainAddIn attaches a custom domain to a Pages project.
|
||||
type domainAddIn struct {
|
||||
// Project is the Pages project name, from the path.
|
||||
Project string `json:"project"`
|
||||
// Name is the custom domain to attach, e.g. "www.acme.com".
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// PagesDomainAdd attaches a custom domain to a Cloudflare Pages project. Requires
|
||||
// org admin. Cloudflare owns validation and certificate issuance from here on.
|
||||
//
|
||||
// Example: {"project": "marketing-site", "name": "www.acme.com"}
|
||||
func (o ops) pagesDomainAdd(ctx context.Context, in *domainAddIn) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
proj, err := pathSeg(c, "project", nameRE)
|
||||
proj, err := seg("project", in.Project, nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
return nil, err
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
return zip.ErrBadRequest("domain name is required")
|
||||
return nil, zip.ErrBadRequest("domain name is required")
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/pages/projects/"+proj+"/domains", map[string]string{"name": name})
|
||||
return cl.relay(ctx, http.MethodPost, "/accounts/"+acct+"/pages/projects/"+proj+"/domains",
|
||||
map[string]string{"name": name})
|
||||
}
|
||||
|
||||
func pagesDomainDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// domainRef addresses one custom domain on one Pages project, both from the path.
|
||||
type domainRef struct {
|
||||
// Project is the Pages project name.
|
||||
Project string `json:"project"`
|
||||
// Domain is the attached custom domain to detach.
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
|
||||
// PagesDomainDelete detaches a custom domain from a Cloudflare Pages project.
|
||||
// Requires org admin.
|
||||
//
|
||||
// Example: {"project": "marketing-site", "domain": "www.acme.com"}
|
||||
func (o ops) pagesDomainDelete(ctx context.Context, in *domainRef) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
proj, err := pathSeg(c, "project", nameRE)
|
||||
proj, err := seg("project", in.Project, nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
dom, err := pathSeg(c, "domain", nameRE)
|
||||
dom, err := seg("domain", in.Domain, nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/pages/projects/"+proj+"/domains/"+dom, nil)
|
||||
return cl.relay(ctx, http.MethodDelete, "/accounts/"+acct+"/pages/projects/"+proj+"/domains/"+dom, nil)
|
||||
}
|
||||
|
||||
+57
-24
@@ -5,48 +5,81 @@ package cloudflare
|
||||
// resolves the caller-org token and account id, then relays the CF response verbatim.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func r2BucketList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/r2/buckets"+query(c, "per_page", "cursor", "name_contains", "order", "direction"), nil)
|
||||
// bucketsIn pages and filters the bucket list. Every field is optional and rides
|
||||
// the query string; each is forwarded to Cloudflare under the same name.
|
||||
type bucketsIn struct {
|
||||
// PerPage is how many buckets one page holds.
|
||||
PerPage string `json:"per_page"`
|
||||
// Cursor continues from the position a previous page returned.
|
||||
Cursor string `json:"cursor"`
|
||||
// NameContains filters to buckets whose name contains this substring.
|
||||
NameContains string `json:"name_contains"`
|
||||
// Order names the field to sort by, and Direction sorts asc or desc.
|
||||
Order string `json:"order"`
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
|
||||
func r2BucketCreate(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// R2BucketList lists the R2 buckets on the org's Cloudflare account. Any org
|
||||
// member may read.
|
||||
func (o ops) r2BucketList(ctx context.Context, in *bucketsIn) (*cfResult, error) {
|
||||
cl, acct, err := o.acctClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
q := forward(map[string]string{
|
||||
"per_page": in.PerPage, "cursor": in.Cursor, "name_contains": in.NameContains,
|
||||
"order": in.Order, "direction": in.Direction,
|
||||
})
|
||||
return cl.relay(ctx, http.MethodGet, "/accounts/"+acct+"/r2/buckets"+q, nil)
|
||||
}
|
||||
|
||||
// bucketCreateIn names a new R2 bucket.
|
||||
type bucketCreateIn struct {
|
||||
// Name is the bucket name to create.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// R2BucketCreate creates an R2 bucket on the org's Cloudflare account. Requires
|
||||
// org admin.
|
||||
//
|
||||
// Example: {"name": "assets"}
|
||||
func (o ops) r2BucketCreate(ctx context.Context, in *bucketCreateIn) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if !nameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("bucket name is invalid")
|
||||
return nil, zip.ErrBadRequest("bucket name is invalid")
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/r2/buckets", map[string]string{"name": name})
|
||||
return cl.relay(ctx, http.MethodPost, "/accounts/"+acct+"/r2/buckets", map[string]string{"name": name})
|
||||
}
|
||||
|
||||
func r2BucketDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// bucketRef addresses one R2 bucket by name, from the path.
|
||||
type bucketRef struct {
|
||||
// Bucket is the R2 bucket name.
|
||||
Bucket string `json:"bucket"`
|
||||
}
|
||||
|
||||
// R2BucketDelete deletes an R2 bucket. Requires org admin. Cloudflare refuses a
|
||||
// bucket that still holds objects, and that refusal is relayed.
|
||||
//
|
||||
// Example: {"bucket": "assets"}
|
||||
func (o ops) r2BucketDelete(ctx context.Context, in *bucketRef) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
bucket, err := pathSeg(c, "bucket", nameRE)
|
||||
bucket, err := seg("bucket", in.Bucket, nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/r2/buckets/"+bucket, nil)
|
||||
return cl.relay(ctx, http.MethodDelete, "/accounts/"+acct+"/r2/buckets/"+bucket, nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package cloudflare
|
||||
|
||||
// relay_wire_test.go — the contract the typed ops must not move.
|
||||
//
|
||||
// Typing this plane was a DESCRIPTION task: every op now carries an In/Out type, so
|
||||
// it reaches the OpenAPI document, the MCP tool list, the CLI and the SDKs — and the
|
||||
// bytes on the wire had to stay exactly what they were. These tests pin the two
|
||||
// halves of that claim: the response is still Cloudflare's own payload, and the set
|
||||
// of routes that are NOT typed ops is a closed, named list rather than a drift.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/openapi"
|
||||
)
|
||||
|
||||
// A relayed payload is Cloudflare's, whatever shape it has: an object, an array, a
|
||||
// JSON null, or an integer too large for a float64 to hold. The last one is the
|
||||
// one that fails silently if a relay ever decodes into `any` and re-encodes — the
|
||||
// id comes back off by one — so it is pinned with an exact string match.
|
||||
func TestRelayIsVerbatim(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, result, want string
|
||||
}{
|
||||
{"object", `{"id":"z1","name":"acme.com"}`, `{"id":"z1","name":"acme.com"}`},
|
||||
{"array", `[{"id":"z1"},{"id":"z2"}]`, `[{"id":"z1"},{"id":"z2"}]`},
|
||||
{"null", `null`, `null`},
|
||||
{"int64 beyond float64", `{"id":9007199254740993}`, `{"id":9007199254740993}`},
|
||||
// An envelope with no result at all is the CF delete shape; it relays the
|
||||
// success acknowledgement, not a bare null.
|
||||
{"no result key", ``, emptyResult},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := &capture{}
|
||||
body := `{"success":true,"errors":[]}`
|
||||
if tc.result != "" {
|
||||
body = `{"success":true,"errors":[],"result":` + tc.result + `}`
|
||||
}
|
||||
app := harness(t, map[string]string{"acme": "tok"}, rec, func(p string) (int, string) {
|
||||
if strings.HasSuffix(p, "/zones") {
|
||||
return 200, body
|
||||
}
|
||||
return 0, ""
|
||||
})
|
||||
status, got := do(t, app, http.MethodGet, "/v1/cloudflare/zones", "u1", "acme", "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", status, got)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("relayed %s, want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The zone id addresses the purge; it must not leak INTO the body Cloudflare
|
||||
// receives. The op's In carries both the path param and the selector, and only the
|
||||
// selector is Cloudflare's — proving the In is the caller's request shape and
|
||||
// PurgeCache is the upstream one.
|
||||
func TestPurgeBodyCarriesOnlyTheSelector(t *testing.T) {
|
||||
const zone = "0123456789abcdef0123456789abcdef"
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"acme": "tok"}, rec, nil)
|
||||
if code, _, _ := doReq(t, app, http.MethodPost, "/v1/cloudflare/zones/"+zone+"/purge",
|
||||
"u1", "acme", true, `{"purge_everything":true}`); code != http.StatusOK {
|
||||
t.Fatalf("purge = %d, want 200", code)
|
||||
}
|
||||
r, ok := rec.find("/purge_cache")
|
||||
if !ok {
|
||||
t.Fatal("purge did not reach Cloudflare")
|
||||
}
|
||||
if got := string(r.body); got != `{"purge_everything":true}` {
|
||||
t.Fatalf("upstream purge body = %s, want only the selector", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A DELETE op takes its target from the URL and carries no request body (zip v1.18+).
|
||||
// Driving one with a body that names a DIFFERENT resource must not move where it
|
||||
// lands: the URL is the addressing authority.
|
||||
func TestDeleteAddressesFromTheURL(t *testing.T) {
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"acme": "tok"}, rec, nil)
|
||||
if code, _, _ := doReq(t, app, http.MethodDelete, "/v1/cloudflare/r2/buckets/real",
|
||||
"u1", "acme", true, `{"bucket":"decoy"}`); code != http.StatusOK {
|
||||
t.Fatalf("delete = %d, want 200", code)
|
||||
}
|
||||
if _, ok := rec.find("/r2/buckets/decoy"); ok {
|
||||
t.Fatal("a request body redirected a DELETE away from the bucket its URL named")
|
||||
}
|
||||
if _, ok := rec.find("/r2/buckets/real"); !ok {
|
||||
t.Fatalf("delete did not address the URL's bucket; got %+v", rec.reqs)
|
||||
}
|
||||
}
|
||||
|
||||
// untypedByDesign is the CLOSED list of /v1/cloudflare operations that are NOT
|
||||
// typed ops, each with the reason it cannot be one. A typed op is a route PLUS a
|
||||
// registry entry — the one value the document, the MCP tool, the CLI command and
|
||||
// the SDK method all come from — so an operation missing from that registry is
|
||||
// invisible to all four. These six are missing on purpose. Addresses are written
|
||||
// the way the DOCUMENT writes them, which is the identity every projection keys on.
|
||||
var untypedByDesign = map[string]string{
|
||||
"POST /v1/cloudflare/ai/run/{wildcard1}": "the request body is whatever the chosen model takes and is " +
|
||||
"forwarded verbatim; the response is often not JSON at all (an image or audio model answers bytes " +
|
||||
"under Cloudflare's own content type), which a typed op cannot emit.",
|
||||
"GET /v1/cloudflare/kv/namespaces/{namespace}/values/{key}": "a KV value is opaque bytes under the " +
|
||||
"content type it was written with; a typed op answers JSON.",
|
||||
"PUT /v1/cloudflare/kv/namespaces/{namespace}/values/{key}": "the request body IS the stored value, " +
|
||||
"under the caller's own content type; a typed In would parse it as JSON.",
|
||||
"POST /v1/cloudflare/d1/databases/{database}/query": "the query body is forwarded to D1 VERBATIM so " +
|
||||
"params and batch fields survive; a typed In drops every field it does not model.",
|
||||
"PUT /v1/cloudflare/workers/scripts/{script}": "the path param `script` (the NAME) and the body field " +
|
||||
"`script` (the module SOURCE) collide, and zip's URL binder gives the path the last word.",
|
||||
"POST /v1/cloudflare/pages/projects/{project}/deployments": "a body this route cannot parse is IGNORED " +
|
||||
"(the deploy falls back to the production branch) where a typed In answers 400.",
|
||||
}
|
||||
|
||||
// cloudflareOps reads BOTH projections of the live router at their one shared
|
||||
// address form: what the document says is served, and which of those carry a typed
|
||||
// registry entry.
|
||||
func cloudflareOps(t *testing.T) (served map[string]bool, typed map[string]string) {
|
||||
t.Helper()
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"acme": "tok"}, rec, nil)
|
||||
|
||||
doc, err := openapi.Spec(app, openapi.Info{Title: "cloudflare", Version: "v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("spec: %v", err)
|
||||
}
|
||||
reg, err := openapi.Typed(app)
|
||||
if err != nil {
|
||||
t.Fatalf("typed registry: %v", err)
|
||||
}
|
||||
served, typed = map[string]bool{}, map[string]string{}
|
||||
for path, item := range doc.Paths {
|
||||
if !strings.HasPrefix(path, "/v1/cloudflare") {
|
||||
continue
|
||||
}
|
||||
for method := range item {
|
||||
served[strings.ToUpper(method)+" "+path] = true
|
||||
}
|
||||
}
|
||||
for key, op := range reg.Ops {
|
||||
if strings.Contains(key, "/v1/cloudflare") {
|
||||
typed[key] = op.Description
|
||||
}
|
||||
}
|
||||
return served, typed
|
||||
}
|
||||
|
||||
// TestEveryRouteIsTypedOrNamed fails when a /v1/cloudflare operation is neither a
|
||||
// typed op nor one of the six above — so the next route added here is typed by
|
||||
// default, and dropping one out of the registry takes a deliberate edit with a reason.
|
||||
func TestEveryRouteIsTypedOrNamed(t *testing.T) {
|
||||
served, typed := cloudflareOps(t)
|
||||
|
||||
var untyped []string
|
||||
for key := range served {
|
||||
if _, ok := typed[key]; ok {
|
||||
continue
|
||||
}
|
||||
if _, named := untypedByDesign[key]; named {
|
||||
continue
|
||||
}
|
||||
untyped = append(untyped, key)
|
||||
}
|
||||
if len(untyped) > 0 {
|
||||
sort.Strings(untyped)
|
||||
t.Errorf("operation(s) with no registry entry and no reason: %s\n"+
|
||||
"A route that is not a typed op has no schema, no prose, no MCP tool, no CLI command and no SDK "+
|
||||
"method. Convert it (zip.Get/Post/... on the group), or add it to untypedByDesign with the reason "+
|
||||
"typing it would move the wire.", strings.Join(untyped, ", "))
|
||||
}
|
||||
// The reasons must describe operations that exist, or the list is stale prose.
|
||||
for key := range untypedByDesign {
|
||||
if !served[key] {
|
||||
t.Errorf("untypedByDesign names %q, which this plane no longer serves", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every typed op must carry lifted prose, because that prose IS the product
|
||||
// surface: it becomes the OpenAPI description AND the MCP tool description a model
|
||||
// reads to pick the tool. zipdoc_gen.go is what carries it into the binary, so an
|
||||
// op added without regenerating shows up here as a nameless tool.
|
||||
func TestEveryTypedOpIsDescribed(t *testing.T) {
|
||||
_, typed := cloudflareOps(t)
|
||||
if len(typed) == 0 {
|
||||
t.Fatal("no typed cloudflare ops in the registry at all")
|
||||
}
|
||||
for key, desc := range typed {
|
||||
if strings.TrimSpace(desc) == "" {
|
||||
t.Errorf("%s has no description — run: go generate -run zipdoc ./apps/cloudflare/...", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
-57
@@ -9,6 +9,7 @@ package cloudflare
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
@@ -16,7 +17,6 @@ import (
|
||||
"net/textproto"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -39,16 +39,25 @@ type WorkerRouteCreate struct {
|
||||
|
||||
// ── scripts ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func workersScriptList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctClient(s, c)
|
||||
// WorkersScriptList lists the Worker scripts on the org's Cloudflare account. Any
|
||||
// org member may read.
|
||||
func (o ops) workersScriptList(ctx context.Context, _ *noInput) (*cfResult, error) {
|
||||
cl, acct, err := o.acctClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/workers/scripts", nil)
|
||||
return cl.relay(ctx, http.MethodGet, "/accounts/"+acct+"/workers/scripts", nil)
|
||||
}
|
||||
|
||||
func workersScriptPut(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// workersScriptPut uploads (or replaces) a module Worker script. Requires org admin.
|
||||
//
|
||||
// NOT a typed op: the path names the script (`:script`) and the body field `script`
|
||||
// carries the module SOURCE. zip's URL binder matches a path param to the In field
|
||||
// of the same name and gives the URL the last word, so a typed In would overwrite
|
||||
// the source with the script name. Renaming either side would move the wire.
|
||||
func (o ops) workersScriptPut(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -68,22 +77,32 @@ func workersScriptPut(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return zip.ErrBadRequest(err.Error())
|
||||
}
|
||||
var out json.RawMessage
|
||||
if err := cl.cfUpload(c.Context(), http.MethodPut, "/accounts/"+acct+"/workers/scripts/"+name, contentType, body, &out); err != nil {
|
||||
if err := cl.cfUpload(ctx, http.MethodPut, "/accounts/"+acct+"/workers/scripts/"+name, contentType, body, &out); err != nil {
|
||||
return cfErr(err)
|
||||
}
|
||||
return writeResult(c, out)
|
||||
}
|
||||
|
||||
func workersScriptDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// scriptRef addresses one Worker script by name, from the path.
|
||||
type scriptRef struct {
|
||||
// Script is the Worker script name.
|
||||
Script string `json:"script"`
|
||||
}
|
||||
|
||||
// WorkersScriptDelete removes a Worker script from the org's Cloudflare account.
|
||||
// Requires org admin. Routes bound to the script stop serving it.
|
||||
//
|
||||
// Example: {"script": "edge-router"}
|
||||
func (o ops) workersScriptDelete(ctx context.Context, in *scriptRef) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
name, err := pathSeg(c, "script", nameRE)
|
||||
name, err := seg("script", in.Script, nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/workers/scripts/"+name, nil)
|
||||
return cl.relay(ctx, http.MethodDelete, "/accounts/"+acct+"/workers/scripts/"+name, nil)
|
||||
}
|
||||
|
||||
// buildWorkerUpload builds the Cloudflare multipart/form-data body for a module
|
||||
@@ -145,84 +164,120 @@ func buildWorkerUpload(in WorkerScriptPut) ([]byte, string, error) {
|
||||
|
||||
// ── workers.dev subdomain ───────────────────────────────────────────────────────
|
||||
|
||||
func workersSubdomainGet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctClient(s, c)
|
||||
// WorkersSubdomainGet reads the org account's workers.dev subdomain — the name
|
||||
// under which every subdomain-enabled script is served. Any org member may read.
|
||||
func (o ops) workersSubdomainGet(ctx context.Context, _ *noInput) (*cfResult, error) {
|
||||
cl, acct, err := o.acctClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/workers/subdomain", nil)
|
||||
return cl.relay(ctx, http.MethodGet, "/accounts/"+acct+"/workers/subdomain", nil)
|
||||
}
|
||||
|
||||
// workersScriptSubdomainSet enables/disables a script on the account workers.dev
|
||||
// subdomain (POST .../scripts/{script}/subdomain {enabled}).
|
||||
func workersScriptSubdomainSet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, acct, err := acctWrite(s, c)
|
||||
// subdomainSetIn toggles one script on the account workers.dev subdomain.
|
||||
type subdomainSetIn struct {
|
||||
// Script is the Worker script name, from the path.
|
||||
Script string `json:"script"`
|
||||
// Enabled publishes the script on <script>.<subdomain>.workers.dev when true,
|
||||
// and withdraws it when false.
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// WorkersScriptSubdomainSet publishes or withdraws one Worker script on the
|
||||
// account's workers.dev subdomain. Requires org admin.
|
||||
//
|
||||
// Example: {"script": "edge-router", "enabled": true}
|
||||
func (o ops) workersScriptSubdomainSet(ctx context.Context, in *subdomainSetIn) (*cfResult, error) {
|
||||
cl, acct, err := o.acctWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
name, err := pathSeg(c, "script", nameRE)
|
||||
name, err := seg("script", in.Script, nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
var in struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/workers/scripts/"+name+"/subdomain", map[string]bool{"enabled": in.Enabled})
|
||||
return cl.relay(ctx, http.MethodPost, "/accounts/"+acct+"/workers/scripts/"+name+"/subdomain",
|
||||
map[string]bool{"enabled": in.Enabled})
|
||||
}
|
||||
|
||||
// ── zone routes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func workersRouteList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authClient(s, c)
|
||||
// WorkersRouteList lists the Worker routes bound within one zone — the URL
|
||||
// patterns that dispatch to a script. Any org member may read. Routes are
|
||||
// zone-scoped, so no account is resolved.
|
||||
//
|
||||
// Example: {"zone": "0123456789abcdef0123456789abcdef"}
|
||||
func (o ops) workersRouteList(ctx context.Context, in *zoneRef) (*cfResult, error) {
|
||||
cl, _, err := o.authClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
zone, err := pathSeg(c, "zone", idRE)
|
||||
zone, err := seg("zone", in.Zone, idRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/zones/"+zone+"/workers/routes", nil)
|
||||
return cl.relay(ctx, http.MethodGet, "/zones/"+zone+"/workers/routes", nil)
|
||||
}
|
||||
|
||||
func workersRouteCreate(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authWrite(s, c)
|
||||
// routeCreateIn binds a Worker script to a URL pattern within a zone.
|
||||
type routeCreateIn struct {
|
||||
// Zone is the 32-hex Cloudflare zone id, from the path.
|
||||
Zone string `json:"zone"`
|
||||
// Pattern is the URL pattern to bind, e.g. "acme.com/api/*".
|
||||
Pattern string `json:"pattern"`
|
||||
// Script is the Worker script to dispatch to. Omit it to leave the pattern
|
||||
// bound to no script, which is how Cloudflare expresses "bypass the Worker here".
|
||||
Script string `json:"script"`
|
||||
}
|
||||
|
||||
// WorkersRouteCreate binds a URL pattern in a zone to a Worker script. Requires
|
||||
// org admin — a route is what puts a script in front of live traffic.
|
||||
//
|
||||
// Example: {"zone": "0123456789abcdef0123456789abcdef", "pattern": "acme.com/api/*", "script": "edge-router"}
|
||||
func (o ops) workersRouteCreate(ctx context.Context, in *routeCreateIn) (*cfResult, error) {
|
||||
cl, _, err := o.authWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
zone, err := pathSeg(c, "zone", idRE)
|
||||
zone, err := seg("zone", in.Zone, idRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in WorkerRouteCreate
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
return nil, err
|
||||
}
|
||||
pattern := strings.TrimSpace(in.Pattern)
|
||||
if pattern == "" {
|
||||
return zip.ErrBadRequest("route pattern is required")
|
||||
return nil, zip.ErrBadRequest("route pattern is required")
|
||||
}
|
||||
body := map[string]string{"pattern": pattern}
|
||||
if sc := strings.TrimSpace(in.Script); sc != "" {
|
||||
body["script"] = sc
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/zones/"+zone+"/workers/routes", body)
|
||||
return cl.relay(ctx, http.MethodPost, "/zones/"+zone+"/workers/routes", body)
|
||||
}
|
||||
|
||||
func workersRouteDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authWrite(s, c)
|
||||
// routeRef addresses one Worker route within one zone, both from the path.
|
||||
type routeRef struct {
|
||||
// Zone is the 32-hex Cloudflare zone id.
|
||||
Zone string `json:"zone"`
|
||||
// Route is the 32-hex Cloudflare route id.
|
||||
Route string `json:"route"`
|
||||
}
|
||||
|
||||
// WorkersRouteDelete unbinds a Worker route, so its pattern stops dispatching to a
|
||||
// script. Requires org admin.
|
||||
//
|
||||
// Example: {"zone": "0123456789abcdef0123456789abcdef", "route": "fedcba9876543210fedcba9876543210"}
|
||||
func (o ops) workersRouteDelete(ctx context.Context, in *routeRef) (*cfResult, error) {
|
||||
cl, _, err := o.authWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
zone, err := pathSeg(c, "zone", idRE)
|
||||
zone, err := seg("zone", in.Zone, idRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
route, err := pathSeg(c, "route", idRE)
|
||||
route, err := seg("route", in.Route, idRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, "/zones/"+zone+"/workers/routes/"+route, nil)
|
||||
return cl.relay(ctx, http.MethodDelete, "/zones/"+zone+"/workers/routes/"+route, nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Code generated by zipdoc; DO NOT EDIT.
|
||||
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("DELETE /v1/cloudflare/d1/databases/:database", zip.Doc{
|
||||
Description: "D1DatabaseDelete deletes a D1 database and everything stored in it. Requires\norg admin.",
|
||||
Fields: map[string]string{
|
||||
"databaseRef.database": "Database is the Cloudflare D1 database id or name.",
|
||||
},
|
||||
Example: json.RawMessage(`{"database":"orders"}`),
|
||||
})
|
||||
zip.Describe("DELETE /v1/cloudflare/kv/namespaces/:namespace", zip.Doc{
|
||||
Description: "KVNamespaceDelete deletes a Workers KV namespace and every key in it. Requires\norg admin.",
|
||||
Fields: map[string]string{
|
||||
"namespaceRef.namespace": "Namespace is the Cloudflare KV namespace id.",
|
||||
},
|
||||
Example: json.RawMessage(`{"namespace":"0123456789abcdef0123456789abcdef"}`),
|
||||
})
|
||||
zip.Describe("DELETE /v1/cloudflare/kv/namespaces/:namespace/values/:key", zip.Doc{
|
||||
Description: "KVValueDelete removes one key from a Workers KV namespace. Requires org admin.",
|
||||
Fields: map[string]string{
|
||||
"valueRef.key": "Key is the key within that namespace. KV keys are broad (up to 512 bytes),\nso this one is escaped rather than charset-restricted.",
|
||||
"valueRef.namespace": "Namespace is the Cloudflare KV namespace id.",
|
||||
},
|
||||
Example: json.RawMessage(`{"namespace":"0123456789abcdef0123456789abcdef","key":"session/abc"}`),
|
||||
})
|
||||
zip.Describe("DELETE /v1/cloudflare/pages/projects/:project", zip.Doc{
|
||||
Description: "PagesDelete deletes a Cloudflare Pages project, and with it every deployment it\nhas ever made. Requires org admin.",
|
||||
Fields: map[string]string{
|
||||
"projectRef.project": "Project is the Pages project name.",
|
||||
},
|
||||
Example: json.RawMessage(`{"project":"marketing-site"}`),
|
||||
})
|
||||
zip.Describe("DELETE /v1/cloudflare/pages/projects/:project/domains/:domain", zip.Doc{
|
||||
Description: "PagesDomainDelete detaches a custom domain from a Cloudflare Pages project.\nRequires org admin.",
|
||||
Fields: map[string]string{
|
||||
"domainRef.domain": "Domain is the attached custom domain to detach.",
|
||||
"domainRef.project": "Project is the Pages project name.",
|
||||
},
|
||||
Example: json.RawMessage(`{"project":"marketing-site","domain":"www.acme.com"}`),
|
||||
})
|
||||
zip.Describe("DELETE /v1/cloudflare/r2/buckets/:bucket", zip.Doc{
|
||||
Description: "R2BucketDelete deletes an R2 bucket. Requires org admin. Cloudflare refuses a\nbucket that still holds objects, and that refusal is relayed.",
|
||||
Fields: map[string]string{
|
||||
"bucketRef.bucket": "Bucket is the R2 bucket name.",
|
||||
},
|
||||
Example: json.RawMessage(`{"bucket":"assets"}`),
|
||||
})
|
||||
zip.Describe("DELETE /v1/cloudflare/workers/scripts/:script", zip.Doc{
|
||||
Description: "WorkersScriptDelete removes a Worker script from the org's Cloudflare account.\nRequires org admin. Routes bound to the script stop serving it.",
|
||||
Fields: map[string]string{
|
||||
"scriptRef.script": "Script is the Worker script name.",
|
||||
},
|
||||
Example: json.RawMessage(`{"script":"edge-router"}`),
|
||||
})
|
||||
zip.Describe("DELETE /v1/cloudflare/workers/zones/:zone/routes/:route", zip.Doc{
|
||||
Description: "WorkersRouteDelete unbinds a Worker route, so its pattern stops dispatching to a\nscript. Requires org admin.",
|
||||
Fields: map[string]string{
|
||||
"routeRef.route": "Route is the 32-hex Cloudflare route id.",
|
||||
"routeRef.zone": "Zone is the 32-hex Cloudflare zone id.",
|
||||
},
|
||||
Example: json.RawMessage(`{"zone":"0123456789abcdef0123456789abcdef","route":"fedcba9876543210fedcba9876543210"}`),
|
||||
})
|
||||
zip.Describe("GET /v1/cloudflare/d1/databases", zip.Doc{
|
||||
Description: "D1DatabaseList lists the D1 databases on the org's Cloudflare account. Any org\nmember may read.",
|
||||
Fields: map[string]string{
|
||||
"databasesIn.name": "Name filters to the database with this name.",
|
||||
"databasesIn.page": "Page is the 1-based page of databases to return.",
|
||||
"databasesIn.per_page": "PerPage is how many databases one page holds.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/cloudflare/kv/namespaces", zip.Doc{
|
||||
Description: "KVNamespaceList lists the Workers KV namespaces on the org's Cloudflare\naccount. Any org member may read.",
|
||||
Fields: map[string]string{
|
||||
"namespacesIn.order": "Order names the field to sort by, and Direction sorts asc or desc.",
|
||||
"namespacesIn.page": "Page is the 1-based page of namespaces to return.",
|
||||
"namespacesIn.per_page": "PerPage is how many namespaces one page holds.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/cloudflare/pages/projects", zip.Doc{
|
||||
Description: "PagesList lists the org's Cloudflare Pages projects. Any org member may read.",
|
||||
})
|
||||
zip.Describe("GET /v1/cloudflare/pages/projects/:project", zip.Doc{
|
||||
Description: "PagesGet reads one Cloudflare Pages project — its build config, deployment\nconfigs and latest deployment. Any org member may read.",
|
||||
Fields: map[string]string{
|
||||
"projectRef.project": "Project is the Pages project name.",
|
||||
},
|
||||
Example: json.RawMessage(`{"project":"marketing-site"}`),
|
||||
})
|
||||
zip.Describe("GET /v1/cloudflare/r2/buckets", zip.Doc{
|
||||
Description: "R2BucketList lists the R2 buckets on the org's Cloudflare account. Any org\nmember may read.",
|
||||
Fields: map[string]string{
|
||||
"bucketsIn.cursor": "Cursor continues from the position a previous page returned.",
|
||||
"bucketsIn.name_contains": "NameContains filters to buckets whose name contains this substring.",
|
||||
"bucketsIn.order": "Order names the field to sort by, and Direction sorts asc or desc.",
|
||||
"bucketsIn.per_page": "PerPage is how many buckets one page holds.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/cloudflare/workers/scripts", zip.Doc{
|
||||
Description: "WorkersScriptList lists the Worker scripts on the org's Cloudflare account. Any\norg member may read.",
|
||||
})
|
||||
zip.Describe("GET /v1/cloudflare/workers/subdomain", zip.Doc{
|
||||
Description: "WorkersSubdomainGet reads the org account's workers.dev subdomain — the name\nunder which every subdomain-enabled script is served. Any org member may read.",
|
||||
})
|
||||
zip.Describe("GET /v1/cloudflare/workers/zones/:zone/routes", zip.Doc{
|
||||
Description: "WorkersRouteList lists the Worker routes bound within one zone — the URL\npatterns that dispatch to a script. Any org member may read. Routes are\nzone-scoped, so no account is resolved.",
|
||||
Fields: map[string]string{
|
||||
"zoneRef.zone": "Zone is the 32-hex Cloudflare zone id.",
|
||||
},
|
||||
Example: json.RawMessage(`{"zone":"0123456789abcdef0123456789abcdef"}`),
|
||||
})
|
||||
zip.Describe("GET /v1/cloudflare/zones", zip.Doc{
|
||||
Description: "ZonesList lists the Cloudflare zones the org's connected API token can see,\npaged and filtered by the query parameters Cloudflare itself accepts. Zones are\ntoken-scoped by Cloudflare, so no account is resolved. Any org member may read.\n\nZone and DNS-record MANAGEMENT is not here: it stays on the Hanzo DNS plane\n(/v1/dns). This only surfaces the Cloudflare zone objects the asset plane needs\n— a zone id is what addresses a Worker route or an analytics read.",
|
||||
Fields: map[string]string{
|
||||
"zonesIn.name": "Name filters to the zone with this domain name.",
|
||||
"zonesIn.order": "Order names the field to sort by, and Direction sorts asc or desc.",
|
||||
"zonesIn.page": "Page is the 1-based page of zones to return.",
|
||||
"zonesIn.per_page": "PerPage is how many zones one page holds.",
|
||||
"zonesIn.status": "Status filters by zone status (active, pending, initializing, …).",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/cloudflare/zones/:zone", zip.Doc{
|
||||
Description: "ZoneGet reads one Cloudflare zone the org's token can see. Any org member may\nread. A zone id the token cannot see is Cloudflare's own not-found, relayed.",
|
||||
Fields: map[string]string{
|
||||
"zoneRef.zone": "Zone is the 32-hex Cloudflare zone id.",
|
||||
},
|
||||
Example: json.RawMessage(`{"zone":"0123456789abcdef0123456789abcdef"}`),
|
||||
})
|
||||
zip.Describe("GET /v1/cloudflare/zones/:zone/analytics", zip.Doc{
|
||||
Description: "ZoneAnalytics reads a zone's Cloudflare traffic dashboard — requests, bandwidth,\nthreats and pageviews over the since/until window. Any org member may read.\n\nA zone whose Cloudflare plan does not serve this endpoint yields Cloudflare's\nOWN error, never a fabricated success.",
|
||||
Fields: map[string]string{
|
||||
"analyticsIn.continuous": "Continuous asks Cloudflare for only fully-aggregated buckets.",
|
||||
"analyticsIn.since": "Since and Until bound the window, in the form Cloudflare accepts — an RFC 3339\ntime or a negative number of minutes from now (\"-1440\" is the last day).",
|
||||
"analyticsIn.zone": "Zone is the 32-hex Cloudflare zone id.",
|
||||
},
|
||||
Example: json.RawMessage(`{"zone":"0123456789abcdef0123456789abcdef","since":"-1440","until":"0"}`),
|
||||
})
|
||||
zip.Describe("POST /v1/cloudflare/d1/databases", zip.Doc{
|
||||
Description: "D1DatabaseCreate creates a D1 database on the org's Cloudflare account.\nRequires org admin.",
|
||||
Fields: map[string]string{
|
||||
"databaseCreateIn.name": "Name is the database name to create.",
|
||||
},
|
||||
Example: json.RawMessage(`{"name":"orders"}`),
|
||||
})
|
||||
zip.Describe("POST /v1/cloudflare/kv/namespaces", zip.Doc{
|
||||
Description: "KVNamespaceCreate creates a Workers KV namespace on the org's Cloudflare\naccount. Requires org admin. Cloudflare mints the namespace id the value routes\naddress.",
|
||||
Fields: map[string]string{
|
||||
"namespaceCreateIn.title": "Title is the namespace's display title. Cloudflare mints the id.",
|
||||
},
|
||||
Example: json.RawMessage(`{"title":"sessions"}`),
|
||||
})
|
||||
zip.Describe("POST /v1/cloudflare/pages/projects", zip.Doc{
|
||||
Description: "PagesCreate creates a Cloudflare Pages project on the org's account. Requires\norg admin. Only the modeled fields reach Cloudflare, so an unmodeled key in the\nrequest is dropped rather than forwarded.",
|
||||
Example: json.RawMessage(`{"name":"marketing-site","production_branch":"main"}`),
|
||||
})
|
||||
zip.Describe("POST /v1/cloudflare/pages/projects/:project/domains", zip.Doc{
|
||||
Description: "PagesDomainAdd attaches a custom domain to a Cloudflare Pages project. Requires\norg admin. Cloudflare owns validation and certificate issuance from here on.",
|
||||
Fields: map[string]string{
|
||||
"domainAddIn.name": "Name is the custom domain to attach, e.g. \"www.acme.com\".",
|
||||
"domainAddIn.project": "Project is the Pages project name, from the path.",
|
||||
},
|
||||
Example: json.RawMessage(`{"project":"marketing-site","name":"www.acme.com"}`),
|
||||
})
|
||||
zip.Describe("POST /v1/cloudflare/r2/buckets", zip.Doc{
|
||||
Description: "R2BucketCreate creates an R2 bucket on the org's Cloudflare account. Requires\norg admin.",
|
||||
Fields: map[string]string{
|
||||
"bucketCreateIn.name": "Name is the bucket name to create.",
|
||||
},
|
||||
Example: json.RawMessage(`{"name":"assets"}`),
|
||||
})
|
||||
zip.Describe("POST /v1/cloudflare/workers/scripts/:script/subdomain", zip.Doc{
|
||||
Description: "WorkersScriptSubdomainSet publishes or withdraws one Worker script on the\naccount's workers.dev subdomain. Requires org admin.",
|
||||
Fields: map[string]string{
|
||||
"subdomainSetIn.enabled": "Enabled publishes the script on <script>.<subdomain>.workers.dev when true,\nand withdraws it when false.",
|
||||
"subdomainSetIn.script": "Script is the Worker script name, from the path.",
|
||||
},
|
||||
Example: json.RawMessage(`{"script":"edge-router","enabled":true}`),
|
||||
})
|
||||
zip.Describe("POST /v1/cloudflare/workers/zones/:zone/routes", zip.Doc{
|
||||
Description: "WorkersRouteCreate binds a URL pattern in a zone to a Worker script. Requires\norg admin — a route is what puts a script in front of live traffic.",
|
||||
Fields: map[string]string{
|
||||
"routeCreateIn.pattern": "Pattern is the URL pattern to bind, e.g. \"acme.com/api/*\".",
|
||||
"routeCreateIn.script": "Script is the Worker script to dispatch to. Omit it to leave the pattern\nbound to no script, which is how Cloudflare expresses \"bypass the Worker here\".",
|
||||
"routeCreateIn.zone": "Zone is the 32-hex Cloudflare zone id, from the path.",
|
||||
},
|
||||
Example: json.RawMessage(`{"zone":"0123456789abcdef0123456789abcdef","pattern":"acme.com/api/*","script":"edge-router"}`),
|
||||
})
|
||||
zip.Describe("POST /v1/cloudflare/zones/:zone/purge", zip.Doc{
|
||||
Description: "ZonePurge drops a zone's Cloudflare edge cache — either the whole zone\n(purge_everything) or exactly the listed file URLs. Requires org admin.\n\nPurging is the one zone-scoped WRITE this plane owns. It is not DNS — no record\nchanges — so it does not belong on /v1/dns, and it is not a connection, so it does\nnot belong on the integrations plane. It is a cache operation on a zone, which is\nwhat this asset plane is for. It takes the admin gate because dropping a zone's\ncache sends every subsequent request to the origin: on a site fronting a small\norigin that is a self-inflicted load spike, so it is a change, not a look.\n\nExactly one selector is required. Cloudflare treats a body with neither as a\nno-op and answers 200, which reads as \"purged\" to a caller that never purged\nanything — the failure we refuse to pass through.",
|
||||
Fields: map[string]string{
|
||||
"purgeIn.files": "Files purges exactly the listed URLs — at most 30, Cloudflare's per-request cap.",
|
||||
"purgeIn.purge_everything": "Everything drops the zone's entire edge cache.",
|
||||
"purgeIn.zone": "Zone is the 32-hex Cloudflare zone id, from the path.",
|
||||
},
|
||||
Example: json.RawMessage(`{"zone":"0123456789abcdef0123456789abcdef","purge_everything":true}`),
|
||||
})
|
||||
}
|
||||
+108
-50
@@ -8,55 +8,103 @@ package cloudflare
|
||||
// they gate on authClient (validated org) — never authWrite.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// maxPurgeFiles is Cloudflare's documented per-request URL cap for a files purge.
|
||||
const maxPurgeFiles = 30
|
||||
|
||||
// zonesList relays GET /zones (the zones the token is scoped to), forwarding only an
|
||||
// allowlisted set of pagination/filter params so a caller can page without arbitrary
|
||||
// passthrough. Zones are token-scoped by Cloudflare, so no account resolution is
|
||||
// needed. Read gate.
|
||||
func zonesList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/zones"+query(c, "page", "per_page", "name", "status", "order", "direction"), nil)
|
||||
// zonesIn pages and filters the zone list. Every field is optional and rides the
|
||||
// query string; each is forwarded to Cloudflare under the same name.
|
||||
type zonesIn struct {
|
||||
// Page is the 1-based page of zones to return.
|
||||
Page string `json:"page"`
|
||||
// PerPage is how many zones one page holds.
|
||||
PerPage string `json:"per_page"`
|
||||
// Name filters to the zone with this domain name.
|
||||
Name string `json:"name"`
|
||||
// Status filters by zone status (active, pending, initializing, …).
|
||||
Status string `json:"status"`
|
||||
// Order names the field to sort by, and Direction sorts asc or desc.
|
||||
Order string `json:"order"`
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
|
||||
// zoneGet relays GET /zones/{zone_id} for one zone the token can see.
|
||||
func zoneGet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authClient(s, c)
|
||||
// ZonesList lists the Cloudflare zones the org's connected API token can see,
|
||||
// paged and filtered by the query parameters Cloudflare itself accepts. Zones are
|
||||
// token-scoped by Cloudflare, so no account is resolved. Any org member may read.
|
||||
//
|
||||
// Zone and DNS-record MANAGEMENT is not here: it stays on the Hanzo DNS plane
|
||||
// (/v1/dns). This only surfaces the Cloudflare zone objects the asset plane needs
|
||||
// — a zone id is what addresses a Worker route or an analytics read.
|
||||
func (o ops) zonesList(ctx context.Context, in *zonesIn) (*cfResult, error) {
|
||||
cl, _, err := o.authClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
zone, err := pathSeg(c, "zone", idRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/zones/"+zone, nil)
|
||||
q := forward(map[string]string{
|
||||
"page": in.Page, "per_page": in.PerPage, "name": in.Name,
|
||||
"status": in.Status, "order": in.Order, "direction": in.Direction,
|
||||
})
|
||||
return cl.relay(ctx, http.MethodGet, "/zones"+q, nil)
|
||||
}
|
||||
|
||||
// zoneAnalytics relays GET /zones/{zone_id}/analytics/dashboard — the zone traffic
|
||||
// analytics read — forwarding the since/until/continuous window params. A zone whose
|
||||
// plan does not serve this endpoint yields Cloudflare's OWN error (relayed via cfErr),
|
||||
// never a fabricated success. Read gate.
|
||||
func zoneAnalytics(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authClient(s, c)
|
||||
// zoneRef addresses one zone. The id is the path segment: the URL is the addressing
|
||||
// authority, so it binds from there whatever a body says.
|
||||
type zoneRef struct {
|
||||
// Zone is the 32-hex Cloudflare zone id.
|
||||
Zone string `json:"zone"`
|
||||
}
|
||||
|
||||
// ZoneGet reads one Cloudflare zone the org's token can see. Any org member may
|
||||
// read. A zone id the token cannot see is Cloudflare's own not-found, relayed.
|
||||
//
|
||||
// Example: {"zone": "0123456789abcdef0123456789abcdef"}
|
||||
func (o ops) zoneGet(ctx context.Context, in *zoneRef) (*cfResult, error) {
|
||||
cl, _, err := o.authClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
zone, err := pathSeg(c, "zone", idRE)
|
||||
zone, err := seg("zone", in.Zone, idRE)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/zones/"+zone+"/analytics/dashboard"+query(c, "since", "until", "continuous"), nil)
|
||||
return cl.relay(ctx, http.MethodGet, "/zones/"+zone, nil)
|
||||
}
|
||||
|
||||
// analyticsIn reads a zone's traffic dashboard over a window.
|
||||
type analyticsIn struct {
|
||||
// Zone is the 32-hex Cloudflare zone id.
|
||||
Zone string `json:"zone"`
|
||||
// Since and Until bound the window, in the form Cloudflare accepts — an RFC 3339
|
||||
// time or a negative number of minutes from now ("-1440" is the last day).
|
||||
Since string `json:"since"`
|
||||
Until string `json:"until"`
|
||||
// Continuous asks Cloudflare for only fully-aggregated buckets.
|
||||
Continuous string `json:"continuous"`
|
||||
}
|
||||
|
||||
// ZoneAnalytics reads a zone's Cloudflare traffic dashboard — requests, bandwidth,
|
||||
// threats and pageviews over the since/until window. Any org member may read.
|
||||
//
|
||||
// A zone whose Cloudflare plan does not serve this endpoint yields Cloudflare's
|
||||
// OWN error, never a fabricated success.
|
||||
//
|
||||
// Example: {"zone": "0123456789abcdef0123456789abcdef", "since": "-1440", "until": "0"}
|
||||
func (o ops) zoneAnalytics(ctx context.Context, in *analyticsIn) (*cfResult, error) {
|
||||
cl, _, err := o.authClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zone, err := seg("zone", in.Zone, idRE)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := forward(map[string]string{"since": in.Since, "until": in.Until, "continuous": in.Continuous})
|
||||
return cl.relay(ctx, http.MethodGet, "/zones/"+zone+"/analytics/dashboard"+q, nil)
|
||||
}
|
||||
|
||||
// PurgeCache is the body of a zone cache purge. Exactly one selector may be set:
|
||||
@@ -68,40 +116,50 @@ type PurgeCache struct {
|
||||
Files []string `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
// zonePurge relays POST /zones/{zone_id}/purge_cache.
|
||||
// purgeIn is the purge request: the zone from the path, plus the one selector.
|
||||
// PurgeCache is the shape sent UPSTREAM; this is the shape the caller sends, and
|
||||
// the two are spelled out separately because the zone is ours and never Cloudflare's.
|
||||
type purgeIn struct {
|
||||
// Zone is the 32-hex Cloudflare zone id, from the path.
|
||||
Zone string `json:"zone"`
|
||||
// Everything drops the zone's entire edge cache.
|
||||
Everything bool `json:"purge_everything"`
|
||||
// Files purges exactly the listed URLs — at most 30, Cloudflare's per-request cap.
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
// ZonePurge drops a zone's Cloudflare edge cache — either the whole zone
|
||||
// (purge_everything) or exactly the listed file URLs. Requires org admin.
|
||||
//
|
||||
// Purging is the one zone-scoped WRITE this plane owns. It is not DNS — no record
|
||||
// changes — so it does not belong on /v1/dns, and it is not a connection, so it does
|
||||
// not belong on the integrations plane. It is a cache operation on a zone, which is
|
||||
// what this asset plane is for.
|
||||
//
|
||||
// It gates on authWrite (org admin), because dropping a zone's cache sends every
|
||||
// subsequent request to the origin: on a site fronting a small origin that is a
|
||||
// self-inflicted load spike, so it is a change, not a look.
|
||||
// what this asset plane is for. It takes the admin gate because dropping a zone's
|
||||
// cache sends every subsequent request to the origin: on a site fronting a small
|
||||
// origin that is a self-inflicted load spike, so it is a change, not a look.
|
||||
//
|
||||
// Exactly one selector is required. Cloudflare treats a body with neither as a
|
||||
// no-op and answers 200, which reads as "purged" to a caller that never purged
|
||||
// anything — the failure we refuse to pass through.
|
||||
func zonePurge(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authWrite(s, c)
|
||||
//
|
||||
// Example: {"zone": "0123456789abcdef0123456789abcdef", "purge_everything": true}
|
||||
func (o ops) zonePurge(ctx context.Context, in *purgeIn) (*cfResult, error) {
|
||||
cl, _, err := o.authWrite(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
zone, err := pathSeg(c, "zone", idRE)
|
||||
zone, err := seg("zone", in.Zone, idRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in PurgeCache
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case in.Everything && len(in.Files) > 0:
|
||||
return zip.ErrBadRequest("purge_everything and files are mutually exclusive")
|
||||
return nil, zip.ErrBadRequest("purge_everything and files are mutually exclusive")
|
||||
case !in.Everything && len(in.Files) == 0:
|
||||
return zip.ErrBadRequest("set purge_everything or a non-empty files list")
|
||||
return nil, zip.ErrBadRequest("set purge_everything or a non-empty files list")
|
||||
case len(in.Files) > maxPurgeFiles:
|
||||
return zip.ErrBadRequest("files exceeds the per-request limit")
|
||||
return nil, zip.ErrBadRequest("files exceeds the per-request limit")
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/zones/"+zone+"/purge_cache", in)
|
||||
return cl.relay(ctx, http.MethodPost, "/zones/"+zone+"/purge_cache",
|
||||
PurgeCache{Everything: in.Everything, Files: in.Files})
|
||||
}
|
||||
|
||||
+767
-35
File diff suppressed because it is too large
Load Diff
+1025
-91
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,13 @@ var allowedRequestUses = map[string]string{
|
||||
"carries. ONE function, which every op in the package asks; it fails closed off the HTTP path. Two ops " +
|
||||
"then reuse the request it hands back for a second, non-identity reason: the CSRF issuer pins " +
|
||||
"Cache-Control on its response, and embed-status reads the SuperAdmin claim.",
|
||||
"apps/cloudflare/cloudflare.go": "authWrite / resolveAccount / the acting-org stamp. A mutation on the " +
|
||||
"org's Cloudflare account requires ORG ADMIN (X-User-IsOrgAdmin), which principal.OrgFrom does not " +
|
||||
"carry; every served response stamps X-Hanzo-Org with the org whose token was used, so a per-org " +
|
||||
"caller can prove no tenant comingling; and the ?account= override is read off the URL rather than " +
|
||||
"modeled as an In field, because zip binds an In field from the BODY too and this route has never " +
|
||||
"accepted an account there. authWrite fails closed off the HTTP path: no request, no attested " +
|
||||
"caller, no mutation.",
|
||||
"apps/search/search.go": "Query resolves the tenant from the validated principal at the top of the op.",
|
||||
"apps/ingress/ingress.go": "admin — the SuperAdmin gate on the fleet EDGE's config. The edge is platform " +
|
||||
"infrastructure (AC-6), so every /v1/ingress op requires SuperAdmin, which is X-User-IsAdmin — a claim " +
|
||||
|
||||
Reference in New Issue
Block a user