@cat-factory/gatekeeper-worker

中文文档
0.7.22发布于 2026/11/04MITTypeScriptES Module构建来源已验证近 30 天下载 2359

The Cloudflare OS Gatekeeper machinery for cat-factory as an installable library: policy-compiled Cap'n Web capabilities over the /api/v1 operation table, per-actor API-key minting, a verified outbound-webhook receiver and the approval inbox that answers

原始使用文档
文档目录12 节
  1. What it is
  2. Purpose and goal
  3. How to use it
  4. What it does
  5. What to configure
  6. /health also reports whether a Cloudflare OS could install …
  7. What to customize: the policy
  8. What a caller holds
  9. Custody, and what it does not promise
  10. Upgrading
  11. Tests
  12. References

@cat-factory/gatekeeper-worker

What it is

The Cloudflare Worker machinery behind a cat-factory Gatekeeper: a credential-holding front end that lets a Cloudflare OS workspace drive cat-factory without an agent ever seeing a credential. Agents hold an object-capability whose methods are exactly what policy granted; the keys stay in Worker secrets and Durable Object storage.

It serves TWO doors onto the same rooms, and which one a caller comes in by decides how it is authorized:

DoorWho comes in by itAuthorization
The GatekeeperVendor entrypointA Cloudflare OS workspace, over a GATEKEEPER_* service bindingHolding the binding
ALL /rpc (Cap'n Web over HTTP)Any other agent runtime that speaks Cap'n WebOS_SHARED_TOKEN

The published Cloudflare OS contract reaches a gatekeeper over native Workers RPC to a WorkerEntrypoint export named GatekeeperVendor; Cap'n Web is that workspace's browser-to-backend and gadget-side protocol, which shares the semantics and not the wire. So /rpc is the door for everything that is NOT a Cloudflare OS deployment, and nothing here is Cloudflare-OS-only.

It is the machinery half of the Gatekeeper family:

PieceWhat it isHow you take it
@cat-factory/gatekeeper-bindingsthe generated operation table this package compiles policy againstcomes in as a dependency
@cat-factory/gatekeeper-worker (this package)the capability surface, key broker, webhook receiver, approval inbox, state DOinstall, and write only a policy
deploy/gatekeeperthe deployment template: policy, wrangler bindings, three lines of wiringcopy, and edit src/policy.config.ts

You install this package and write one file. Everything a deployment differs by is the policy; everything else, from the capability surface down to the Durable Object the minted keys live in, comes from here. That split is the goal: upgrading the machinery is a version bump rather than a merge against files you have edited, and "did you get the security-relevant fix" is answerable from a version number.

Purpose and goal

Its purpose is to put cat-factory behind an organization's own governance pane: per-actor credentials so every run traces back to a person, per-tier operation grants so an agent can reach only what its operator decided, field masking, and the platform's parked decisions surfaced as an approval inbox instead of a polling loop.

It is a consumer of the stable public surface: it rides /api/v1 and the outbound webhook delivery contract through @cat-factory/sdk and reaches nothing else. A cat-factory deployment that has never heard of it is byte-for-byte unchanged.

How to use it

The starting point is the template at deploy/gatekeeper: copy it, point it at your workspace, and edit its src/policy.config.ts. A deployment's whole Worker is:

import { createGatekeeperWorker } from '@cat-factory/gatekeeper-worker'
import { POLICY } from './policy.config'

// wrangler resolves `class_name` against the Worker's OWN exports, so the Durable Object class has
// to be named here even though it is implemented in this package.
export { GatekeeperState } from '@cat-factory/gatekeeper-worker'

export default createGatekeeperWorker({ policy: POLICY })
// policy.config.ts: the `/policy` entry point carries the vocabulary without the Worker runtime,
// so this file and its tests load anywhere.
import { DECISION_BINDINGS, type GatekeeperPolicy } from '@cat-factory/gatekeeper-worker/policy'

export const POLICY: GatekeeperPolicy = {
  defaultTier: null, // no implicit access: an ungranted actor gets `unknown_actor`, not a capability
  tiers: {
    observer: { description: 'Read the board and runs.', keyScope: 'read', allow: '*' },
    approver: {
      description: 'Answer a run’s parked decisions.',
      keyScope: 'decide',
      // Derived from the answerer table, never transcribed: a run parks on thirteen different
      // things and the surface carries more than forty operations for answering them.
      allow: ['tasks_get_run', ...DECISION_BINDINGS],
      mask: ['run.pullRequestUrl'],
    },
  },
  grants: { '[email protected]': 'approver' },
}

@cloudflare/workers-types is a required peer, not an optional one. Every type this package publishes is stated in terms of the Worker globals (ExportedHandler, DurableObjectNamespace, Request) and the Durable Object base class comes from cloudflare:workers, so a consumer without those types cannot compile the three lines above, let alone anything else. It is a peer rather than a dependency because the globals are ambient: two copies in one tree redeclare each other, so the version has to be the consumer's.

What it does

  • Object-capability bindings. An agent holds an object whose METHODS are the operations policy granted it. There is no allow-list consulted per call, because there is nothing to consult: an operation the tier does not carry is not a method that refuses, it is absent.
  • The Cloudflare OS object model, as a facade over all of it. GatekeeperVendor → CatFactoryAccount → CatFactoryResource → session, with describe(), getSupportedResources() and getTypeScriptTypes() all PROJECTIONS of the operation table rather than transcriptions of it. A resource is the paired cat-factory workspace, named by a URLPattern over the deployment origin: one Gatekeeper serves one workspace, because the provisioning key it holds is scoped to one.
  • The workspace's approval queue, in front of every call. On the entrypoint path each read is authorized before it is MADE, and each write is SUBMITTED and performed only when the workspace applies it. Reads that serve captured agent text are marked unshareable, actions carry the consequence the table states, and nothing is offered for unattended auto-approval while the surface annotates no write as safe. The tier policy underneath stays the floor.
  • Per-actor credentials. Each caller gets their own cat-factory key, minted through POST /api/v1/keys at the tier's scope and stamped with your identity for that person (externalIdentity), so a run traces back to a human and role-scoped merge policy stays real. Minting is claimed before it runs and re-mints once on a 401, so concurrent first calls mint once and rotating the provisioning key heals instead of wedging.
  • Approvals as an inbox, for every park. The platform's outbound webhook delivers parked-decision cards; the Worker verifies the HMAC over the raw bytes, dedupes on deliveryId, and raises a card. A run can stop on thirteen different things and each has an answerer keyed on the SDK's own kind union, so a park the platform adds fails this package's build rather than reporting stale forever. Answering re-reads the run's live decisions and posts through the caller's own key.
  • Run lifecycle without polling. run.started / run.completed / run.failed land as a runs_watched() projection, and a terminal event settles that run's open cards.
  • Hooks, so the workspace is pushed rather than asked. A session binds a callback through the workspace's own bindHook, the workspace enables it when it is ready to, and each card or run transition is then pushed: a fresh callback per delivery, authorized as the observation it is. What a hook pushes is exactly what approvals_list() and runs_watched() answer, which stay the truth: a delivery a hook missed is still readable there, and hooks_bound() reports the miss rather than leaving it to be inferred from a quiet inbox. The fan-out runs BEHIND the delivery's acknowledgement and each push is bounded by its own deadline, so a workspace that hangs costs a notification and never the platform's retry budget.
  • Sharing that is verified, not assumed. A workspace user shared onto a bound resource is admitted only when their OWN account's tier reaches everything the resource's tier reaches, and masks no more; a tier that can read captured agent text is never shareable at all. The observer has to hold an account THIS deployment minted, checked before any tier is resolved: an unknown id would otherwise resolve to the auto-provisioned tier, which is the tier every account here holds, so a viewer from another vendor entirely measured up as identical to the owner. Anything the Gatekeeper cannot answer (an observer with no verifier, an account it never minted) is a refusal that says which it is.
  • Arguments checked against what each operation declares. An argument no operation reads used to be dropped on the way through, so a filter nobody applied came back as an answer shaped like a filtered one. It is now a refusal naming what the operation does take, made before a key is minted or an approval is spent.
  • Self-enrolment and offboarding. The endpoint registers itself under a caller-chosen webhook id, hourly and idempotently. POST /admin/retire?actorId=… revokes every key minted for one person, upstream first and then here.

One thing to tell whoever writes the agent on the other side: a task filed with only a title parks immediately, before any agent runs. cat-factory reduces a task's own authored fields before the first dispatch, and a missing description is a blocking finding, so tasks_create + tasks_start with a bare title yields a run stopped on an input-gate decision rather than one that is working. That park is answerable from here like any other, but the cheaper fix is filing work that says what it wants.

What to configure

Everything operational comes from the Worker's environment, through two mechanisms that are not interchangeable: the vars and the Durable Object binding are written in the template's wrangler.toml, and the three credentials are secrets, put with wrangler secret put into the platform's secret store. A credential in a config file is a credential in a repository, so a refusal names the mechanism its binding actually takes rather than offering both.

BindingKindWhat it is
CAT_FACTORY_BASE_URLvarThe cat-factory deployment this Gatekeeper is paired with.
PUBLIC_URLvarThis Worker's own public origin; deliveries arrive at <PUBLIC_URL>/webhook.
WEBHOOK_IDvarThe outbound-webhook id to enrol under. Caller-chosen; keep it stable.
PROVISIONING_KEYsecretAn admin cat-factory API key. Mints per-actor keys; nothing else.
WEBHOOK_SECRETsecret16-200 chars. Registered with the endpoint and verified on every delivery.
OS_SHARED_TOKENsecretThe bearer the paired OS deployment presents on every RPC call.
STATEDurable ObjectA namespace bound to GatekeeperState: cards, dedupe log, minted keys.

A missing binding is answered as a 503 naming it and how it is set, never defaulted: there is no safe stand-in for a credential or for the identity of the deployment it talks to. GET /health asks the whole table at once rather than the bindings a given request path happens to read, so a deployment that is wired for liveness and unwired for traffic reads as what it is.

The Worker serves five routes:

RouteAuthWhat it is
POST /webhookdelivery HMACThe platform's outbound deliveries. Verified over raw bytes.
ALL /rpcOS_SHARED_TOKENCap'n Web, for an agent runtime that is not a Cloudflare OS.
POST /admin/enrollOS_SHARED_TOKENRe-assert the webhook registration. Also runs hourly on cron.
POST /admin/retire?actorId=…OS_SHARED_TOKENOffboarding: revoke every key minted for one OS user.
GET /healthnoneGreen when every binding is set and the policy compiles.

/rpc is bearer-gated because a Worker with a route attached is reachable by anyone who finds it, and a capability surface whose only defence is obscurity is not one. The Cloudflare OS path does not come through here at all: it arrives on a service binding, which never traverses the internet and which only that deployment's operator can write, so holding it IS the authorization and a second secret in front of it would protect nothing.

/health also reports whether a Cloudflare OS could install this

Two things decide that, and neither has a request path of its own: the entry module must export the four names the object model resolves (GatekeeperVendor, CatFactoryAccount, CatFactoryResource, CatFactoryVerifier), and the policy must name an autoProvisionedTier. A workspace that finds either missing does not get an error anyone monitors; it never finishes installing. So a green response carries the answer beside ok:

{ "ok": true, "os": { "discoverable": true, "blockers": [], "limitations": [] } }

limitations is the same report for what does NOT stop an install. CatFactoryHookController is reached only when a session binds a hook, so a deployment missing it installs, serves and answers exactly as before and refuses approvals_subscribe(); calling that a blocker would turn a working deployment red, and omitting it would leave the gap to be discovered from an agent's failed call.

It is REPORTED, never folded into the status, because a Gatekeeper serving /rpc and nothing else is a supported deployment: this package promises that door to consumers that are not a Cloudflare OS, and turning their liveness red on a version bump would be this route answering a question nobody asked it. A deployment that wants discovery keys a monitor on os.discoverable; each entry in blockers carries a reason (missing_exports, no_auto_provisioned_tier) and a detail naming the fix, and both are reported in one pass so a half-wired deployment is not wired one redeploy at a time.

What to customize: the policy

The policy is the ONE thing a deployment writes, and it is an argument (createGatekeeperWorker({ policy })), never a file this package reads. A GatekeeperPolicy has three fields:

  • defaultTier: the tier an actor with no explicit grant receives, or null to refuse unknown actors (unknown_actor). null is the shipped default and the safe one: adding a person is then a deliberate edit.
  • tiers: named TierPolicy entries, each carrying:
    • description: prose the OS shows beside the tier.
    • keyScope: the scope of the per-actor key minted for this tier (read / write / decide). It is also the ceiling on the grants; admin is refused outright, because POST /api/v1/keys cannot mint it and a tier asking for it is asking for the Gatekeeper's own provisioning secret.
    • allow: binding names to grant, or '*' for everything within keyScope.
    • deny: binding names to subtract from allow. Applied last, so a deny always wins; the template uses it to keep the debug surface (model prompts, captured output) away from a '*' read tier.
    • mask: dotted paths redacted from every result before it reaches the caller (see below).
  • grants: OS actor identity (whatever the OS authenticates and passes to connect()) to tier name.

A policy is compiled against the LIVE operation table and only ever SUBTRACTS from bindingsWithinScope(tier.keyScope), so a tier cannot grant above the key backing it, and a retired or misspelled operation is a refusal to serve (PolicyError) rather than a method that 403s on every call. Two rules are worth keeping whatever else you change:

  • Grant by name above read. '*' is honest for a read-only tier and dangerous above it: a deployment that adds an operation ships it to every '*' tier on upgrade with nobody deciding to.
  • Keep keyScope as low as the grants allow. It is the scope of the key minted for each actor, so it is the blast radius of that actor's credential.

Masking replaces, never deletes. A masked leaf becomes the exported MASKED sentinel ([masked by gatekeeper policy]) rather than disappearing, because a removed key and a key the platform had no value for read identically to the consuming agent, and they are different facts. Paths are dotted and traverse arrays element-wise (steps.status masks every step's status); a path that matches nothing is not an error, because result shapes legitimately vary by operation.

What a caller holds

connect({ actorId, label? }) on the /rpc session resolves the actor's tier and returns the capability. actorId is the OS's own authenticated identity for the person, and it is the ONLY claim the Gatekeeper trusts: nothing the caller sends picks a tier. Beyond the granted operation methods, every capability carries ten reserved methods:

  • tier(): who the caller is acting as (actor, tier name, description, key scope).
  • bindings(): the granted operations, each with its scope floor, consequence (cautious default applied) and argument shape, so the OS can run its own approval governance per call.
  • withheld(): every binding the deployment serves that this capability does NOT carry, with the reason. The four reasons are kept apart on purpose: not_in_policy and denied_by_policy are questions for the policy's author, above_key_scope is a different one (raise the tier's key, or accept the ceiling), and not_relayable is neither: an SSE stream or a binary blob cannot cross a Cap'n Web call, so the fix is to ask another way (poll tasks_get_run instead of the event stream).
  • approvals_list(), approvals_inspect(cardId), approvals_answer(cardId, input): the approval inbox; see the template README for the flow and the three answer outcomes.
  • runs_watched(): the run-lifecycle projection built from the run.* webhook events.
  • approvals_subscribe(callback), runs_subscribe(callback): bind a hook, so the two projections above are PUSHED. The callback needs one method (onApprovalCard(card) / onRunEvent(state)), and a card is pushed on every transition it makes, settlement by a terminal run event included. Both refuse on the /rpc door, which brings no approval queue to register a hook with and nothing to authorize a delivery against; the refusal names the two reads that answer the same question. A workspace that does not take the binding (its queue serves no hooks, or a person declined) is a hook_bind_refused carrying the cause verbatim, because those two need opposite fixes and nothing at this seam can tell them apart.
  • hooks_bound(): what this account has enabled, with what each hook has been pushed (deliveries), what it could not be pushed (missed), what the workspace refused (failures) and whether it is still live. A hook goes quiet when the durable object is evicted between deliveries, because the workspace's callback source is a stub and cannot be stored; that reads as live: false with a rising missed, and the remedy is to bind again. Re-binding from the same gadget RE-ARMS the same registration rather than adding a second: a hook is identified by where its deliveries land, and its counters carry over, since they are the history that prompted the re-arm.

Refusals from a live Gatekeeper are GatekeeperErrors carrying a machine-readable reason (unknown_actor, card_not_found, ambiguous_park, …), the same role the platform's own details.reason plays: an OS Gadget maps it to copy and a remedy. Operator mistakes are PolicyErrors raised at compile time, before any capability exists, so a misconfigured Gatekeeper serves nothing rather than serving methods that fail.

Custody, and what it does not promise

The provisioning key is a Worker secret and never leaves the platform's secret store. The per-actor keys it mints live in the Durable Object's storage: outside every agent's reach, but at rest in your account. If that is not acceptable, mint per call and revoke after, at the cost of a key row per operation.

What this enforces is which operations an actor may reach and on whose credential. What a run then does inside cat-factory is governed by cat-factory's own merge policy and approvals.

Upgrading

Upgrade this package. @cat-factory/gatekeeper-bindings arrives as its dependency, pinned to an exact version, so bumping this package IS how the operation table moves and there is nothing to keep in step by hand. Do not add a direct dependency on the bindings to keep them "together": an exact pin plus a second range installs a SECOND copy of the table, while policy still compiles against the one resolved here. Everything a policy names is re-exported from @cat-factory/gatekeeper-worker/policy, so a deployment never needs that dependency.

Version skew is reported rather than absorbed. A policy naming an operation newer than the installed table fails with a PolicyError telling you to upgrade, and the ladder helpers throw on a scope rung they do not carry, so a deployment ahead of your packages reads as skew, never as a key with no permissions.

Tests

The suite runs inside real workerd under @cloudflare/vitest-plugin, against a Worker built from this package's own factory with a real Durable Object, real WebCrypto and a real Cap'n Web client, talking to a scripted cat-factory origin bound as the pool's outbound service. The credential-custody story IS "the key is a Worker secret", so a Node mock of a Worker would prove nothing about it.

pnpm --filter @cat-factory/gatekeeper-worker test:run

test/live/ is the same Worker with the scripted origin taken away. A fixture agrees with this package by construction, so a request shape the bindings and the SDK both consider correct can only be wrong against a real deployment: the live specs enrol on the real webhook collection, mint a real per-actor key (and recover from its revocation), forward the everyday loop, and answer a run that really parked, off the card the platform's own notification raises. They are run by @cat-factory/sdk-smoketest, which owns the deployment they need, so this package carries no Postgres-shaped devDependency:

DATABASE_URL=... pnpm --filter @cat-factory/sdk-smoketest run smoketest -- --only=gatekeeper

What that deliberately does not cover is a delivery that TRAVELLED: the platform refuses to register a loopback endpoint, so the receiver is driven with an envelope the suite signs around the platform's own notification object.

test/os-live/ is the third leg, and it takes away the fake the other two share: the workspace. Cloudflare OS's own @gadgets/integration-tests toolkit boots the real workshop-backend beside this Worker under wrangler's test harness, so a real workspace discovers the vendor off a service binding, mints an account, binds the paired deployment as a resource and shares it. Nightly, pinned to a partner commit by GATEKEEPER_OS_REF, and non-blocking by living in a workflow of its own (.github/workflows/gatekeeper-os.yml). To run it yourself, clone the partner repository inside this one and point the suite at it, from the repository root:

git clone https://github.com/cloudflare/cloudflare-os.git .cloudflare-os
# The commit the nightly pins is GATEKEEPER_OS_REF in .github/workflows/gatekeeper-os.yml.
# Running against a different one is fine; it is then your run that is unpinned, not the lane's.
git -C .cloudflare-os checkout <that commit>
(cd .cloudflare-os && pnpm install --frozen-lockfile --ignore-scripts \
  && pnpm --filter @gadgets/workshop-backend run build:format-blueprints)
GATEKEEPER_OS_DIR="$PWD/.cloudflare-os" pnpm --filter @cat-factory/gatekeeper-worker test:os

The clone goes inside this repository because wrangler's test harness boots the partner's Worker and this one under a single root, and the root the suite hands it is this repository: a checkout beside it cannot be booted, and the config refuses one rather than letting the harness report a Worker it could not find. .cloudflare-os is gitignored for this.

It exists for the three seams no other suite here structurally reaches: the entrypoint NAMES (the workspace resolves them and never asks this package what they are called), the stubs handed over (createAccount() returns something the workspace persists, and getGatekeeperClassFor() a class only the workspace's own machinery can instantiate), and the transcribed protocol in src/os/protocol.ts, which a hermetic suite cannot tell from a shape that has fallen behind. Its first run found a real one: without the allow_irrevocable_stub_storage compatibility flag, a workspace cannot store the account stub, so every account anyone connected failed. What it does NOT reach is a session: the harness runs no gadget code, so startSession is never called and the approval queue, the argument checks and the answerers stay with the hermetic suite. Two legs, two questions.

References

版本与发布记录

@cat-factory/gatekeeper-worker 共发布 64 个版本,首次发布于 2026/08/08;过去 12 个月发布了 64 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.7.22latest2026/11/045 天前
0.7.212026/11/045 天前
0.7.202026/11/045 天前
0.7.192026/11/045 天前
0.7.182026/11/045 天前
0.7.172026/10/1723 天前

关于 @cat-factory/gatekeeper-worker 的常见问题

@cat-factory/gatekeeper-worker 是什么?

The Cloudflare OS Gatekeeper machinery for cat-factory as an installable library: policy-compiled Cap'n Web capabilities over the /api/v1 operation table, per-actor API-key minting, a verified outbound-webhook receiver and the approval inbox that answers

如何安装 @cat-factory/gatekeeper-worker?

在项目目录运行 npm install @cat-factory/gatekeeper-worker,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@cloudflare/workers-types(>=4)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@cat-factory/gatekeeper-worker 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@cat-factory/gatekeeper-worker 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@cat-factory/gatekeeper-worker 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@cat-factory/gatekeeper-worker 可以商用吗?

@cat-factory/gatekeeper-worker 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@cat-factory/gatekeeper-worker 的下载量有多少?

根据 npm 官方下载统计,@cat-factory/gatekeeper-worker 在 2026/10/09 – 2026/11/07 这 30 天内被下载 2,359 次,日均约 79 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@cat-factory/gatekeeper-worker 还在维护吗?

最新版本 0.7.22 发布于 2026/11/04(5 天前);至今共发布 64 个版本,其中过去 12 个月发布了 64 个;首次发布于 2026/08/08。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。