Routing is handled by the packages/router origin worker, which serves frontend assets, dispatches API and blueprint requests to the workshop backend, and delegates /gatekeeper/<name>/* paths to dynamically discovered service bindings. Admin operations flow through a single enforced chokepoint in getGatekeeperClassFor() before capability minting, with admin status checked once when AdminApi is obtained rather than per-method. Three pieces form the admin system: the AdminSettings Durable Object as sole writer of AdminConfig, the ADMINS binding (usernames) on the backend worker, and the #isAdmin() check at capability-acquisition time that gates access to the adminIsBlueprintFeatured() and adminSetBlueprintFeatured() RPCs. In the capability model, a capability is an unforgeable object granting access to a specific operation. Cloudflare OS enforces access control at mint time via getGatekeeperClassFor() rather than at each call site, preventing downstream bypass.
packages/router is the public origin worker of a deployed Cloudflare OS instance: it serves workshop-frontend assets and routes /api/* and /blueprint-screenshot/* to the workshop backend, and /gatekeeper/<name>/* to whichever gatekeepers are bound — discovered by scanning GATEKEEPER_* service bindings, so installing a gatekeeper is purely a binding change.[1] endpointOfResourceUrl() in packages/mcp-shared/src/scope.ts strips the fragment from a resource URL to recover the bare endpoint, passing it through URL so two spellings of the same endpoint cannot be treated as different endpoints.[2]
The AdminSettings Durable Object is the sole writer of AdminConfig; it mirrors the config to the .adminConfig KV key so hot-path code reads it with one cheap KV get via readAdminConfig(env).[1] user.ts:getGatekeeperClassFor() is the single enforcement chokepoint where disabled gatekeepers and resources are blocked before a capability is minted; gadget and agent code cannot bypass it.[1] Admin operations are exposed as an AdminApi capability obtained via AuthenticatedApi.getAdminApi(), which returns null for non-admins; the #isAdmin() check happens once when the capability is minted, so individual methods do not re-check.[1] Admin usernames are configured via the ADMINS binding (an array of usernames) on the backend worker; admins gain access to AuthenticatedApi.adminIsBlueprintFeatured() and AuthenticatedApi.adminSetBlueprintFeatured() RPCs.[3]
The GitHub Gatekeeper identifies itself throughout the system using the VENDOR_ID constant "github".[4] Three environment variables are read from Cloudflare.Env: BASE_URL (the mount URL), CLIENT_ID, and CLIENT_SECRET (GitHub OAuth app credentials). When BASE_URL is absent it defaults to "http://localhost:8787/gatekeeper/github", matching local wrangler dev conventions.[4] If CLIENT_ID or CLIENT_SECRET are absent, ensureConfigured throws "The GitHub gatekeeper is not configured." and the OAuth callback page renders NOT_CONFIGURED_HTML rather than proceeding.[4]
The GitHub Gatekeeper requests OAuth scopes ["repo", "read:user", "user:email"] for full repository access, and a minimal ["read:user", "user:email"] set when connecting in auth-only (sign-in) mode.[4] Nonces used during OAuth initiation and at the callback each have a 10-minute lifetime (INITIATION_NONCE_LIFETIME_MS and OAUTH_NONCE_LIFETIME_MS), and are 32 bytes of cryptographically random data hex-encoded.[4] After a successful OAuth callback, the gatekeeper serves SELF_CLOSING_HTML — a page that calls window.close() — so the popup tab dismisses automatically.[4]
Three resource kinds are supported — "repo", "issue", and "pull" — encoded in the ResourceKind type and exposed as SUPPORTED_RESOURCES with URL patterns https://github.com/:owner/:repo, https://github.com/:owner/:repo/issues/:number, and https://github.com/:owner/:repo/pull/:number.[4] Cache TTLs: individual entities (issues, PRs) expire after 30 s (ENTITY_CACHE_TTL_MS), list results after 15 s (LIST_CACHE_TTL_MS), and the viewer identity after 5 min (VIEWER_CACHE_TTL_MS).[4] Discussion sync uses a 5 s overlap window (DISCUSSION_SYNC_OVERLAP_MS) and bails out after 500 entries (DISCUSSION_SYNC_BAIL_LIMIT) to cap memory and request volume; reply-target resolution stops after 50 hops (MAX_REPLY_TARGET_HOPS) to prevent unbounded comment-thread traversal.[4]
The GitHubAction discriminated union covers eleven mutation types: createIssue, createPullRequest, setTitle, setBody, addLabels, removeLabels, changeState, postComment, postReview, replyToDiffComment, and mergePullRequest.[4] Pending actions are stored as StoredActionRecord values with a StoredActionState of "staged", "pending", "approved", or "rejected", plus optional appliedAt, rejectedAt, and revertInfo metadata.[4] Create-type actions (createIssue, createPullRequest) carry a provisionalId field so the gatekeeper can track provisional resources before the real GitHub ID is available post-approval.[4] Revert information is stored as a GitHubRevertInfo discriminated union — issueComment or reviewComment — keyed by commentId, enabling rollback of posted comments.[4]
The GitHub logo is embedded as an inline data:image/svg+xml URI by URL-encoding the imported SVG, avoiding a separate asset request.[4] Three configurator UI modules — GitHubIssueConfiguratorUI, GitHubPullRequestConfiguratorUI, and GitHubRepoConfiguratorUI — are imported from ./github-configurators, each paired with a pre-built HTML bundle from the ./generated/ directory.[4]
In packages/gatekeeper-cloudflare/src/cloudflare.ts, the HTTP entrypoint is used only to initiate and complete the OAuth flow; all other product functionality is served over RPC/DOs.[5] BASE_URL defaults to http://localhost:8787/gatekeeper/cloudflare when not set; CLIENT_ID and CLIENT_SECRET are optional env vars that may be omitted from wrangler.jsonc and supplied as secrets.[5] If CLIENT_ID or CLIENT_SECRET is missing when the OAuth initiation URL is hit, the worker returns a NOT_CONFIGURED_HTML page instructing the user to consult the README, rather than throwing or silently failing. Internally, the UserAccount Durable Object also throws "The Cloudflare Gatekeeper is not configured." when #config() is called without those values.[5] The OAuth flow uses a two-stage nonce pattern: an "initiation" nonce (valid 10 minutes) is verified and replaced with a fresh "oauth" nonce plus PKCE pair when beginOAuthFlow is called; only one nonce is active at a time. Access tokens have a 60-second expiry safety margin (ACCESS_TOKEN_EXPIRY_SAFETY_MS).[5] Nonce comparison in cloudflare.ts also uses constantTimeEqual (backed by crypto.subtle.timingSafeEqual) to avoid timing-oracle attacks.[5] connectAccount accepts a scopes option: when options?.scopes === "auth", it uses AUTH_SCOPES; otherwise it uses FULL_SCOPES. Auth-only grants are stored as ephemeral: true and dropped shortly after the email is read.[5] On success the OAuth callback serves a self-closing HTML page that calls window.close(); on an invalid or stale nonce it returns an "Authorization Link Expired" page covering both the initiation and OAuth stages.[5] The Cloudflare vendor logo is embedded as a data:image/svg+xml URI (the orange cloud SVG) so it can be rendered directly as the vendor/account avatar without a separate network request.[5] PKCE (Proof Key for Code Exchange) is an OAuth extension that binds an authorization request to its token exchange using a one-time verifier/challenge pair, preventing authorization-code interception attacks.
The GatekeeperVendor class (decorated @validateRpc()) currently returns an empty array from getSupportedResources() — the Cloudflare gatekeeper provides auth only, with no gadget or agent resource types yet.[5] The CloudflareGatekeeperUser interface in packages/workshop-shared/src/cloudflare-gatekeeper.ts extends the generic GatekeeperUser contract with a getUsableAccessToken() method that returns a currently-usable (refreshed if needed) Cloudflare API access token for the connected account, or null if the connection is broken or expired.[6] getUsableAccessToken() is Workshop-only — never exposed to gadgets or agents — and is used specifically by the AI Gateway billing flow to read the credit balance and route BYOK inference through the account's default AI Gateway.[6] CloudflareGatekeeperUser is explicitly an extension point: more Cloudflare capabilities (Workers logs, R2, etc.) are intended to be added to it in the future.[6]
The Context Library worker supports private per-account collections and public per-domain collections; the vendor auto-provisions accounts that expose a read-only agent singleton and a management UI.[7] ContextApiImpl in packages/gatekeeper-context/src/context-api.ts is decorated with @validateRpc() and extends RpcTarget, meaning all exposed methods are subject to Cap'n Web runtime RPC type validation.[8] Creating a public collection requires admin access (#assertAdmin()); non-admins can only create private collections.[8] Read access (#assertCanRead) is granted to any caller who either owns the collection privately or the collection is public; all others receive "Collection not found or you don't have access.".[8] Write access (#assertCanWrite) is granted only to the private-collection owner or an admin acting on a public collection; all other callers receive the same "Collection not found or you don't have access." error.[8] Only collection owners or admins can manually trigger an artifact sync via syncContextCollectionArtifactSource; read-path non-owners may trigger a background stale-while-revalidate sync but have no direct control.[8]
createContextCollection generates a UUID via crypto.randomUUID(), initializes the collection Durable Object first, then registers it in either the user library (private) or domain registry (public); if registration fails, the now-unreachable collection is deleted.[8] Collection Durable Object stubs are addressed by the composite key domainName(domain, id), and user-library stubs by domainName(domain, accountId), scoping all lookups to the sharing domain.[8] getViewerInfo() surfaces both the caller's admin status and whether Git-backed collections are enabled (!!env.ARTIFACTS), letting clients conditionally show Git collection UI.[8] Git-backed collections require the ARTIFACTS environment binding; any Git-related operation throws "Git-backed Context collections are not enabled." if env.ARTIFACTS is falsy. The binding is optional — deployments without an Artifacts R2 bucket can still use the gatekeeper.[8][9]
SchedulerGatekeeper is a Durable Object that extends DurableObject and implements the Gatekeeper<ScheduleSession> interface, exposing a ScheduleSession to agents.[10] SchedulerGatekeeper.describe() returns resource metadata including url: "scheduler://tasks", tsType: "ScheduleSession", and suggestedBindingName: "SCHEDULER".[10] SchedulerGatekeeper is read-only: applyAction, rejectAction, and revertAction all throw "Scheduled Tasks is read-only and implements no actions.", and getAutoApprovableActions() always returns an empty array.[10] SchedulerGatekeeper.getAgentCatalog() always returns null; schedule discovery happens through list() instead.[10] SchedulerGatekeeper.startSession() guards against a session being scoped to the account's own ID — if workspaceId === accountId, it throws "Invalid inherited scheduler workspace scope.".[10]
ScheduleSessionImpl.every() registers an interval schedule anchored to the current wall-clock time of registration (registeredAt = this.#now()). The Scheduler also supports finite recurrence bounds, allowing scheduled tasks to be configured with a maximum number of occurrences — passing an occurrences limit to a one-shot runAt schedule throws TypeError: "Occurrence limits apply only to recurring schedules.".[10] ScheduleSessionImpl.list() requires an authorizeObservation approval from the ApprovalQueue before returning the list of schedules.[10] ScheduleSessionImpl implements [Symbol.dispose](), which disposes the approvalQueue stub; callers should use the session within a using block to avoid stub leaks.[10] enableScheduleController strips the accountId from ScheduleControllerProps before passing activation data to driver.enable(), keeping account identity out of the persisted activation record.[10] ScheduleManagementApi is a read-only RpcTarget that exposes only a list() method (backed by driver.listAccount), providing account-level schedule visibility without mutation authority.[10]
The harness in packages/integration-tests/src/harness.ts is parameterized over gatekeepers deliberately: adding a new gatekeeper's test suite means pointing the harness at its package and plugging in a handler module, not forking the harness file.[11] The harness uses binding suffix "TEST" for the fixture gatekeeper, from which the Workshop derives vendor ID "test"; both constants are exported as TEST_GATEKEEPER_BINDING and TEST_VENDOR_ID.[11] workshopConfig injects only the gatekeeper service bindings the suite requested, so buildGatekeeperVendorMap() discovers exactly those vendors and the observer-config prompt has no surprise rows.[11]
packages/workshop-backend is the kernel of Cloudflare OS and is held to a higher review bar: every exported member of the workshop-shared public API must have a doc-comment, hand-written interfaces that mirror RPC interfaces with as unknown as casts are forbidden, and changes must prefer reusing existing mechanisms over adding parallel ones.[1]
Sources
AGENTS.mdpackages/mcp-shared/src/scope.tsdocs/blueprints.mdpackages/gatekeeper-github/src/github.tspackages/gatekeeper-cloudflare/src/cloudflare.tspackages/workshop-shared/src/cloudflare-gatekeeper.tspackages/gatekeeper-context/src/index.tspackages/gatekeeper-context/src/context-api.tspackages/gatekeeper-context/wrangler.jsoncpackages/gatekeeper-scheduler/src/scheduler.tspackages/integration-tests/src/harness.ts