This project is building a platform for "vibe coded" personal applications and AI agents that run inside a strong sandbox.
The following files are commonly important to reference:
- packages/workshop-shared/node_modules/capnweb/README.md: Explains how to use Cap'n Web RPC, which is used extensively for client-server communications.
- packages/workshop-shared/src/api.ts: Defines the RPC API used between the frontend and backend.
The project structure is:
- packages/workshop-frontend: The Gadgets Workshop UI.
- This is a pure single-page app, running entirely client-side.
- It speaks to the backend using an RPC API over a persistent WebSocket connection.
- Uses React, Kumo UI (https://kumo-ui.com/api/component-registry), Phosphor icons, and Vite.
- packages/workshop-backend: The Gadgets Workshop server.
- Runs on Cloudflare Workers.
- This is the kernel: it defines the architecture and is held to a higher bar than UI/gatekeeper code. Reviewers read every line of
workshop-backendand of API changes inworkshop-shared, so keep diffs here small and elegant. Concretely: doc-comment every exported member of theworkshop-sharedpublic API (types, consts, and functions — not just interfaces); never introduce a hand-written interface that mirrors an RPC interface plus anas unknown ascast (derive from the real type instead, or rethink the design); and prefer reusing existing mechanisms over adding parallel ones. Capability-based security note: a resource becomes "ambient" (auto-injected) only by user/admin configuration — a gatekeeper must never assert its own ambience. When a change to this package is large, split it by concern into separate PRs (and at minimum group commits soworkshop-backend/workshop-sharedcan be reviewed apart from UI), since fewer kernel lines = easier review. format-blueprints/holds the output format blueprints the deployment ships with, committed as reviewable source: each<name>/containsblueprint.jsonplus the gadget code underfiles/.scripts/build-format-blueprints.tsreconstructs their archive representation (override the source directory withFORMAT_BLUEPRINTS_DIR) into the gitignoredsrc/generated/format-blueprints.ts, sobuildandtestboth run the generator first. Replace one withpnpm import:format-blueprint <export.gadget> <blueprintId>, or add one withpnpm import:format-blueprint <export.gadget> --new <name>; never edit ablueprintIdafter deploy, since the install and promotion are keyed on it and a rename orphans the old entry. Seeformat-blueprints/README.md.
- packages/workshop-shared: Shared API definitions between client and server.
- This defines the application's RPC interface.
- The RPC protocol is Cap'n Web, which has similar semantics to Cloudflare's Worker-to-Worker RPC system, while being able to run in a browser over WebSocket. Read the readme for details.
- packages/configurator-ui: Type-only component helpers used by optional gatekeeper resource configurator UI modules.
- Gatekeeper configurator UI modules are compiled by
scripts/build-gatekeeper-configurator.tsas part of package builds.
- Gatekeeper configurator UI modules are compiled by
- packages/gatekeeper-*: Gatekeeper workers for external service integrations.
- Each gatekeeper runs as a separate Cloudflare Worker — with one exception the prefix does not capture: a
gatekeeper-*package with nowrangler.jsoncis a library, not a worker (gatekeeper-kithere;gatekeeper-sharedin the internal repo). Deployable discovery is config-gated, not name-gated —readDeployablePackagesinscripts/release/manifest-lib.tskeys solely on the presence ofwrangler.jsonc, andrun-dev-server.tsrequires it too — so adding one to a library package is what would make it deployable, at which pointworkerKindwould classify it a gatekeeper by prefix and the deploy wizard would demandCLIENT_ID/CLIENT_SECRETfor it.manifest-lib.test.tsfails first if that ever happens. - Gatekeepers handle OAuth flows and provide sandboxed access to external APIs.
- A gatekeeper may declare
VendorDescription.autoProvisionsAccount: it can mint a connected account with no OAuth flow (viaGatekeeperVendor.createAccount(), which takes no user identity). For such gatekeepers the deployment admin picks a per-vendor mode in the admin Gatekeepers panel — disabled / optional / enabled (default optional) — resolved inprovisioning-policy.ts:enabledauto-provisions the account for every user (forced, and hidden from the Connectors list),optionallets each user opt in from the Connectors page, anddisabledoffers it to no one (existing accounts go dormant). The Workshop persists the account in the user DO like any connected account (the account capability — not an asserted identity — is the authority thereafter). The account (aGatekeeperUser) declares in itsAccountDescriptionwhether it provides an agent singleton (singleton: { tsType }) and/or a management UI (providesUi). The Workshop auto-provides the singleton to the owner's workspaces as an ambient gatekeeper record, folded into each chat's env as a named chat binding (named by the gatekeeper'ssuggestedBindingName; seeprepareChatBindingsin overseer.ts) that the agent reads inexecuteCode(getSession/getAgentCatalog), each read recorded as an observation. It is not bound to any gadget by default — most gadgets never call it programmatically — but the agent may wire it into a gadget's binding list withsetGadgetBindingwhen the gadget's persistent code needs it. The UI is hosted at/gatekeepers/$appId(the gatekeeper's vendor id, e.g./gatekeepers/context) viastartAppUi({ isAdmin }). The two are orthogonal — an account can declare either, both, or neither.
- Each gatekeeper runs as a separate Cloudflare Worker — with one exception the prefix does not capture: a
- packages/mcp-shared: Shared implementation behind the two MCP gatekeepers —
gatekeeper-mcp(endpoints a user pastes) andgatekeeper-mcp-portal(one admin-configured portal). Not a Worker; a library both import, holding the MCP client, the OAuth chain, the account DO base, the resource-URL scope grammar, and the queued-action store. Seepackages/mcp-shared/README.mdand each connector's README.- The trust boundary is
tools.ts, and nothing outside it reads a tool's annotations: a tool the server declaresreadOnlyHint: trueruns as an observation, everything else is queued for approval, and auto-applying a write additionally requires avettedendpoint — which only the portal can produce, viaMCP_PORTAL_TRUST_ANNOTATIONS. - OAuth uses the official
@modelcontextprotocol/client; always give SDK OAuth operationssdkFetch(...)so every request and redirect retains endpoint and SSRF checks.
- The trust boundary is
- packages/gatekeeper-context: The Context Library — a gatekeeper whose account provides a singleton read session + a management UI, for authoring collections of context documents that agents read as observations. Collections have one of two visibilities: private (owned by a single account, readable/writable only by that account) and public (created/edited only by deployment admins, readable by everyone and auto-enabled for all users). It owns its state in three Durable Objects (
ContextCollectionDurableObjectfor content,UserLibraryDurableObjectfor each account's own private collections,LibraryRegistryDurableObjectfor the domain's public set) plus a KV namespace. All data is namespaced by asharingDomain(from the binding's props, seedomain.ts) so multiple workshops sharing one gatekeeper instance stay isolated.- Its
GatekeeperVendorentrypoint (bound asGATEKEEPER_CONTEXT) declaresautoProvisionsAccountand mints aContextAccountviacreateAccount()(no user identity is passed in; the account keys its private data by its own generatedaccountId). The account exposes the agent read session (getSession()), collection discovery metadata (getAgentCatalog()), and a management UI (startAppUi({ isAdmin })). The UI is a single-file React SPA inapp/(Vite + Tailwind + Kumo) bundled bybuild-app.mjsintosrc/generated/app.txt.
- Its
- packages/gatekeeper-scheduler: Scheduled Tasks — an auto-provisioned gatekeeper whose account provides an ambient singleton for registering persistent workspace callbacks plus a read-only management UI. One account-scoped
ScheduleDriverDurable Object stores enabled schedules and delivers them from a shared alarm; hook enablement remains in the Workshop Connections UI. - packages/gatekeeper-cloudflare: Cloudflare OAuth, serving three unrelated purposes from one connected account — sign-in (
AUTH_GATEKEEPERS), AI Gateway billing, and Workers Observability read-only telemetry resources. Two resource granularities (whole account, or one Worker) both map to the single indivisibleworkers-observability.readscope, so the capability boundary is the binding, not the grant.- Layering:
observability-api.tsis the only place that talks HTTP;observability-parse.tsvalidates every response (no blind casts);observability-session.tsis the agent-facing session, where every read funnels through one#observeso no path returns data unaudited;observability-discovery.tsderives field names/values from a sampled events query. - Scopes fail closed to
BILLING_SCOPES, and an omittedresourceUrlPatterns("all resource types") is distinct from[]("none"): a billing-only connection must not silently acquire telemetry access, and recording a wider grant than was made would makeensureResourcesshort-circuit into a binding that 403s with no way to re-prompt. - Three provider behaviours return wrong-but-plausible data with no error, so none is trusted (all verified against a live account):
telemetry/keys/valuesignore thefiltersthey accept, so a constrained discovery call is answered from a filteredtelemetry/querysample or it would disclose the whole account; an unknown filter key matches nothing, which bites because a log's own fields are returned nested undersourcebut indexed under their bare name ({event: "x"}→source.event, queried asevent, henceobservabilityFieldKeyaccepting thesource.alias wherever a caller names a field); and/accountspages at 20 by default, so it must be walked to the end. - Worker-scoped bindings prepend an immutable
$metadata.servicefilter and re-filter the response, since a filter the provider silently ignored would leak another Worker's telemetry. A dropped event proves the filter was not applied, so the provider's owncountis then withheld rather than reported (it would count the whole account) and the drop is logged aterror;statisticsis kept, since it describes what the query cost rather than how much matched. Pagination cursors come from the provider's raw events rather than the surviving ones, so a fully-foreign page can't stall pagination and hide the caller's own older data. Trace summaries are account-only (their shape describes the whole cross-service trace); a Worker binding can still fetch its own events for a known trace id.calculate()is the one read with no second line of defence — an aggregate can't be un-mixed — so it rests solely on the injected filter; that is accepted and documented on the method, with the group-by fix left as a follow-up. - A provider error message can quote a caller-supplied filter value back, so only its numeric
codesare logged — filter values stay out of the audit trail (summarizeFilter), and the message travels to the caller who caused it. - Tests are two vitest projects:
vitest.config.ts(Node, pure logic) andvitest.worker.config.ts(workerd, forRpcTarget/RpcStub/Durable Objects). The workerd suite reaches the gatekeeper through aTestHooksDurable Object because aDurableObjectClasscarryingctx.propsis only reachable viactx.facets— the way the overseer instantiates it. src/configurator/*.tsxduplicate the resource-URL grammar fromresources.tsand must:build-gatekeeper-configurator.mjstranspiles each per-file, stripping only@gadgets/configurator-uiand type-only imports, so they cannot import runtime helpers.__tests__/configurator-url.test.tskeeps the copies in step, andconfigurator-fields.test.tsdrivesrenderagainst a mocked runtime — the runtime'sclearFieldsonly drops an autocomplete's typed query, so a dependent field must also be nulled throughsetValuesor the stale value silently survives into the resource URL.
- Layering:
- packages/router: The public origin of a deployed gadgets instance. Serves the workshop-frontend assets and routes by path prefix:
/api/*and/blueprint-screenshot/*to the workshop backend,/gatekeeper/<name>/*to whichever gatekeepers are bound (discovered by scanning its ownGATEKEEPER_*service bindings, so installing a gatekeeper is purely a binding change). The same worker doubles as the dev router (pnpm dev-server): with noASSETSbinding it proxies frontend requests to the Vite dev server instead.
Deployment admin settings (the /admin panel) follow a few conventions worth knowing when extending them:
packages/workshop-backend/src/admin-config.tsdefinesAdminConfig— the deployment's "soft" customizations: agent instructions, banners/theme, and which gatekeeper connectors/resources are offered (plus the three-state mode for auto-provisioning gatekeepers, seeprovisioning-policy.ts). Connectors/resources default to enabled and the admin UI opts them out; auto-provisioning gatekeepers default to optional. Authentication/authorization config (sign-in providers viaAUTH_GATEKEEPERS, password login viaDISABLE_PASSWORD_AUTH) is deliberately NOT here — it stays env-var driven (auth/config.ts) so it can't be changed by a compromised admin session.- The
AdminSettingsdurable object owns the authoritativeAdminConfigand mirrors it to a single reserved KV key (.adminConfig, seeisReservedBlueprintKey()), so hot-path code (connect/agent) reads it with one cheap KV get viareadAdminConfig(env). The DO is the only writer (updateAdminConfig(patch)). - Admin operations are exposed as an
AdminApicapability obtained viaAuthenticatedApi.getAdminApi()(returns null for non-admins). The#isAdmin()check happens once when the capability is minted, so the individual methods don't re-check. user.ts:getGatekeeperClassFor()is the single core chokepoint where disabled gatekeepers/resources are enforced before a capability is minted (gadget/agent code can't reach it directly).
Release pipeline (scripts/release/) — how customer instances get deployed:
build-release.tsbundles every deployable worker byte-identically (wrangler dry-run with the pinned wrangler), builds the Access-mode frontend asset build, and generates the release manifest — the contract between this repo's CI and the deploy service, produced bymanifest-lib.tsfrom each package's wrangler.jsonc with account-specific values replaced by placeholders ($ACCOUNT_ID,$WORKER_NAME(...),$SECRET(...),$PUBLIC_BASE_URL, ...).upload-release.tsmirrors the release to R2 content-addressed, manifest last; with--candidatethe manifest lands undercandidates/<id>/(invisible to the deploy service) so e2e can verify it, andpromote-release.tsthen copies it toreleases/<id>/— publishing is that single all-or-nothing manifest copy. The copy is not isolated against concurrent promotions, so CI serializes promote runs (a GitLab resource group) and the script's newer-release guard skips candidates that a later release has already superseded.- The manifest is covered by a golden-file test; after an intentional manifest change, regenerate with
UPDATE_GOLDEN=1 node --test scripts/release/manifest-lib.test.tsand review the golden diff. - Running the flow by hand (upload and promote need
R2_ENDPOINT,R2_BUCKET,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY):node scripts/release/build-release.ts --out release-out— build everything intorelease-out/(id defaults tor<CI_PIPELINE_IID>-<sha7>in CI,dev-<timestamp>locally; override with--release-id <id>).node scripts/release/upload-release.ts --release release-out --candidate— mirror to R2; omit--candidateto publish directly (bypasses the gate — CI never does this).node scripts/release/promote-release.ts --release-id <id>— copy the verified candidate's manifest intoreleases/<id>/.
- Deploy-wizard configuration: an installable gatekeeper's user-supplied inputs default to OAuth
CLIENT_ID/CLIENT_SECRETsecrets; a per-packagedeploy-inputs.jsonoverrides them, andNO_DEFAULT_CRED_INPUTSinmanifest-lib.tsopts out gatekeepers that take no third-party OAuth app credentials (the wizard blocks Install on unfilled secret inputs, so a spurious default makes a gatekeeper uninstallable). Backend instance-state vars (ADMINS,DEPLOY_URL, ...) are injected by the deploy service at PUT time, never manifest-templated.
To test changes:
-
Run
pnpm buildto type-check, orvp run -F <package> buildfor one package — most packages declarebuildas a task rather than a script, andpnpm --filtercannot see a task. It is a type check and codegen pass, not a compile: every package buttyped-storageisnoEmit, because nothing imports the others'dist— wrangler and vite bundle from source.typed-storageemits because itsexportsresolves todist/index.js. A re-run with nothing changed replays from the task cache — see below. -
Run
pnpm testto run unit tests, though as of this writing most packages don't have tests yet. It runs every package'stesttask, including@gadgets/scripts, whose suites run undernode --testrather than vitest. -
The cached per-package test run is a Vite+
testtask in each package'svite.config.tsrather than atestscript, so itsinputcan exclude the scratch paths vitest writes and reads back (scripts/vitest-task-vite-config.ts, shared by all of them). Gatekeepers with a configurator UI re-exportwithTestsfromgatekeeper-configurator-vite-config.tsto get both tasks at once; the ones with no test files re-export its default instead, becausevitest runexits 1 when it finds none.integration-testsis the exception to the shared exclusions, viavitestTaskWithExclusions. Everywhere else.wrangler/validateis regenerated by the task that reads it, so it is dropped from the fingerprint; here the two trees it loads (workshop-backend's, frombuild:integration-worker, and the fixture gatekeeper's, frombuild:test-gatekeeper) are prebuilt by its dependencies and are the only path by which backend source reaches this suite at all — so they are tracked, and only.wrangler/{tmp,state}(wrangler's per-run scratch) are excluded. That works because capnweb-validate's output is byte-deterministic and neither worker rebuilds during the run:harness.tsdeletes the Workshop'sconfig.buildwhenWORKSHOP_INTEGRATION_PREBUILTis set, and the fixture'swrangler.jsoncdeclares no build.build:integration-workeris correspondingly cached now, with{auto: true}minus**/.wrangler/**asinputand an explicitoutput: ['.wrangler/validate/**']— a cache hit has to leave the tree on disk, because its consumer reads it rather than rebuilding it. Its codegen prerequisites stay uncached, which is what keeps the generated modules current when its fingerprint is taken.- The same package's watch mode needs the Worker's inputs named explicitly, since wrangler bundles them outside Vitest's module graph.
src/worker-inputs.tsis the single table behind the watcher roots,forceRerunTriggersand the delete handler insrc/global-setup.ts— vitest'sonFileDeletenever consultsforceRerunTriggers, so a deletion is rerun by hand there.
-
Every command in that shared
testtask runs underscripts/with-timeout.ts, so a wedged run fails fast instead of stalling the wholevp run: it kills the command's entire process tree and exits 124 (GNUtimeout's code) after 60s with no output, or 600s in total.TESTS_WITH_TIMEOUT_DISABLE=1turns it off. -
Two ways to run one package's tests:
pnpm --filter <package> test:rungoes straight to vitest,vp run -F <package> testgoes through the cache. The cached path replays instantly when the package is untouched, but its fingerprinting and archiving lose to plain vitest on a package you just edited — by more than the whole suite costs on a small one. Usetest:runwhile iterating andpnpm testto verify. The direct script istest:runrather thantestbecause a task may not share a name with a script. -
pnpm buildandpnpm cleanarevp run --filter=!cloudflare-os <task>(throughscripts/vp/run.ts, see the concurrency bullet below), notpnpm run --recursive <task>. Vite+ runs the same per-package scripts and tasks, in dependency order, but caches each one against its inputs, so an unchanged package replays its previous output instead of re-running. Commands joined with&&— or given as an array in a task — are cached as separate entries, so a package whose codegen is fresh can still re-run itstsc.vp run --last-detailsexplains every hit and miss, which is the thing to read when a build is slower than expected. Don't reintroduce a root script that callspnpm run --recursive. -
A cached
vprun strips the environment. Each task and script sees only a built-in set (PATH,HOME,CI,NODE_OPTIONS, …). Anything else is invisible to the command and absent from the fingerprint, so a build that depends on an env var silently ignores it and no warning says so. A var can only be declared on a task —env/untrackedEnvdon't exist on a package.json script — so any build that reads one has to be a task.workshop-frontend'sbuilddeclaresenv: ['VITE_*'], which both forwards the flags and folds them into the fingerprint, so a changed value is a reported cache miss rather than a stale bundle replayed. PreferenvoveruntrackedEnvfor anything that changes the output.scripts/env-passthrough.test.tsfails on any build-time env read that isn't accounted for. -
Declaring
envon one task does not help a sibling script that does the same work. A script duplicating a task's command takes the stripped path and the declaration buys nothing. So when you find a task withenv, check what actually invokes that command. The configurator gatekeepers get it right by having nobuildscript at all —buildis a task that is onlytscanddependsOn: ['build:configurator'].gatekeeper-contextandgatekeeper-schedulertake the other legitimate route, nestingvp run --cache build:appinside theirbuildscript, which vp inlines so the declaration still applies. -
envfingerprints the value, not what it points at.workshop-backend'sbuildis thereforecache: false, notenv: ['FORMAT_BLUEPRINTS_DIR']: the variable names a blueprint directory outside the workspace, so with the path held fixed, edits inside it are invisible and a staleformat-blueprints.tsreplays. An uncached task runs with the full ambient environment, so it needs noenvdeclaration. Same caution for any var naming a path outside the workspace. -
pnpm build,pnpm testandpnpm cleanuse--filter=!cloudflare-osrather than-rbecause each root script is thevp runinvocation:-rselects the root too, so it would re-enter itself and race a nested whole-workspace run against the outer one. Vite+ folds a nestedvp run …back into its parent, but anode …script is opaque to that, so with these going throughscripts/vp/run.tsthe filter is the only thing preventing the recursion -
"singleThreaded": trueis set in the roottsconfig.json(and mirrored in the two standalonetsconfig.app.jsons that don't extend it), so everytscrun is single-threaded without per-script flags. tsgo's default mode splits the program across parallel checker instances with separate type caches, and since every file here touches capnweb's instantiation-heavy recursive generics, each checker re-derives the same huge type graphs: measured on workshop-backend, 6.9x the types and 4.2x the instantiations of a single checker, 7.3s/1.9GB vs 2.0s/0.7GB single-threaded (tsc 5.9 was 4.1s/0.9GB). Single-threaded is both the fastest and the smallest configuration, and it makes build tasks cheap enough for the computed concurrency (next bullet). Tests are the separate risk: the workerd fleets are the memory hogs there, and an OOM-killed (exit 137) workerd child wedges its vitest parent forever instead of failing. The watchdog above now turns that into an exit 124 after 60s of silence rather than a hang, so suspect memory first when you see one and drop vp's concurrency. -
Caching is off for tasks that read a path they also write, which is why
workshop-frontend'sbuildexcludes its owndist/frominput— without that it never cached (the gatekeeper SPA bundles are the same shape; see thebuild:appbullet below). Thetesttasks needed the same for the scratch paths vitest and wrangler write undernode_modules/.vite,node_modules/.vite-tempand.wrangler;scripts/vitest-task-vite-config.tscovers which and why, and that list is unlikely to be closed — when a test task stops caching,--last-detailsnames the path it read and wrote. This is also why no tsconfig setsincremental:tscreads its own.tsbuildinfoand writes it back, taking the whole type check out of the task cache to save less than the cache does (measured: 2% hits and 21.1s on a clean tree, against 65% and 13.4s without it). Don't add it back without re-measuringvp run --last-details. -
pnpm dev-serverbuilds the gatekeeper UIs before startingwrangler dev, through two concurrentvp run -r --cachecalls —build:configuratorandbuild:app:dev(vp runtakes one task each). The two runs share a single machine-derived concurrency limit, computed once and divided between them, floored at vp's default of 4 (scripts/vp/concurrency.ts), so together they stay inside the one budget instead of each claiming all of it.vpselects packages by which ones declare the task or script, so a new gatekeeper needs one of those to be built here at all, the same requirementpnpm buildhas. Watchers spawn only after the pre-flight finishes: every watch mode builds before it watches, so an earlier spawn would put two processes on the samesrc/generatedfiles. Two dev-only details keep startup fast:- The generated dev configs spawn each worker's
build.commandbinary directly (node <resolved entry>, resolved through the package's ownnode_modules) instead of throughpnpm exec, which costs ~0.33s of process startup per call — paid for every worker, and again on each rebuild, all of it on the startup critical path. Commands that don't resolve are left as written.wrangler devis reached the same way. - The app watchers are deferred until Wrangler is listening (TCP poll, 60s backstop):
vite build --watchcan't skip its initial build and these are the largest builds in the repo. Hencespawnrather thanexecFileSyncat the tail ofrun-dev-server.ts— but shutdown is still driven by Wrangler's exit, because Ctrl-C reaches the whole process group and exiting out from under Wrangler would orphan its workerd children.
- The generated dev configs spawn each worker's
-
build:appis a Vite+ task in each gatekeeper'svite.config.tsrather than a package.json script so itsinputcan be stated explicitly:{auto: true}minus**/dist-app/**,**/src/generated/**and**/.wrangler/**atbase: "workspace", plus an explicitoutput. Automatic tracking alone never cached it, because the build writes into the same package tracking hashes as its input. Two traps: the exclusions have to be workspace-wide or the gatekeepers invalidate each other, and only directory contents can be excluded, not the directories themselves, sopnpm cleanstill costs one cold build. -
build:app:devis the same build withminify: false, run by thepnpm dev-serverpre-flight so itsapp.txtmatches what the watcher's un-skippable initial build will write — otherwiseemitAppTextrewrites the file and Wrangler restarts the worker mid-startup. It captures onlyapp.txt, sincedist-app/has no reader outsidevite.app.config.ts.buildanddeploystill usebuild:app, so nothing unminified ships, andbuild-app.mjsalways setsGATEKEEPER_APP_UNMINIFIEDexplicitly — an inherited value would otherwise make a production build unminified and get it cached that way.Two structural constraints explain the file layout. Vite+ reads per-package settings only from
vite.config.*, which the SPA's own build config occupied, so that moved tovite.app.config.ts(referenced bybuild-app.mjs -c,tsconfig.vite.jsonand gatekeeper-context's__tests__/vite-config.test.ts). And a task may not share a name with a package.json script, so thebuild:appscript is gone andbuildcallsvp run --cache build:appinstead,deploythe same with--no-cache. Don't define the task in the workspace-root config: it gets created for every package, including the root, which then fails. -
The packages whose tests run in workerd (
router,typed-storage,backend-utils,workshop-backend,gatekeeper-scheduler,gatekeeper-cloudflare,gatekeeper-kit) loadscripts/assert-workerd.tsas asetupFilesentry. It throws unlessnavigator.userAgentisCloudflare-Workers, so a@cloudflare/vitest-pool-workerspool that fails to start fails the suite instead of silently falling back to Node — which otherwise looks like a pass in the packages that import nocloudflare:*module. Don't remove it to make a suite green.
Linting (oxlint, via Vite+):
pnpm lintruns what CI enforces:lint:check(oxlint),types:scriptsandtypes:check. Run this before pushing.- Individual scripts:
pnpm lint:check/pnpm lint:fix—vp lint, i.e. oxlint driven by Vite+ (rules in thelintblock of the rootvite.config.ts;correctness+suspiciousas errors). Vite+ pins the oxlint it runs (1.76.0), so there is no separateoxlintdependency to drift from it and no.oxlintrc.jsonbeside the config — one toolchain config, one version. Diagnostics are identical to what running that oxlint directly would emit.pnpm types:check— an alias forpnpm build. They were separate scripts running the same recursivetsctwice; one name is kept for habit and the other because the codegen prerequisites hang off it.vp lintis not part ofvp run, so it has no task cache; it takes about a second regardless.
- Unused function parameters and caught errors are not lint-enforced; unused imports and local variables are still errors.
- Some rules are kept as warnings (e.g.
no-shadow) for incremental cleanup; warnings don't block CI. - Type-aware oxlint rules are intentionally not enabled yet. The type-aware engine is tsgo (TypeScript 7), which is now also the workspace
tsc, and every package type-checks under it (capnweb 0.11's sharedRpcPromisealias fixed the TS2321/TS2589 instantiation-depth errors that used to block three packages). But nobody has run the type-aware rules themselves: expect a triage pass, and noteno-floating-promisesconflicts with RPC promise pipelining (below), which intentionally leaves promises unawaited. Type safety is still enforced bytscthroughpnpm types:checkandpnpm build. - The
typescriptcatalog entry is 7.0.2 (tsgo), but TS 7's main export is./lib/version.cjs— the compiler API is gone from it — so everything that still needs that API gets its own JS-based compiler.scripts/build-gatekeeper-configurator.ts(and the mcp-shared schema test) import the roottypescript6alias (npm:typescript@6.0.3);capnweb-validate(0.2.4+) ships its own cappedtypescriptdependency for the@validateRpctransform.typed-storage, the only package emitting declarations, sets the"rootDir": "./src"TS 7 requires (TS5011). - No tsconfig sets
baseUrl, and none should. Everypathsentry here is an explicit relative path, whichtscresolves against the tsconfig's own directory, sobaseUrlbought nothing — and TypeScript 7 removed the option outright (TS5102).
IMPORTANT: This repository uses pnpm, not npm. Always use pnpm.
IMPORTANT: Remember when using RPC to use promise pipelining whenever possible. Cap'n Web implements promise pipelining (similar to Cap'n Proto). This means that if an RPC returns a stub, it's not necessary to await the RPC -- the promise itself can be used in place of the stub. Also, Cap'n Web lets you use the promise for a future result (even if it isn't a stub) in the arguments for another call; the promise will be replaced with its resolution on the server side before delivering the arguments. See the Cap'n Web README.md for more details.
IMPORTANT: When using React's useState(), the state value cannot be an RPC stub. At runtime, all stubs appear to be callable (because the system doesn't actually know if the stub points to a function on the server side or not). But the setter returned by useState() has different behavior if passed a function (including any callable object): it calls the function in order to get the state. In order to avoid this problem, whenever a useState() state will contain an RpcStub, it's important to wrap the stub in an object, and set the state to that object instead.
IMPORTANT: RPC stubs must be disposed to prevent resource leaks on the server side. Call stub[Symbol.dispose]() when the stub is no longer needed (or use a using declaration where possible). In particular, when a React component obtains a stub in a useEffect, the cleanup function should dispose the stub.
IMPORTANT: All RPC interface implementations should use the annotation @validateRpc() to apply capnweb-validate, which installs auto-generated runtime type validation matching the interface's TypeScript signatures. Do not write redundant validation code that duplicates the checks capnweb-validate already covers.
IMPORTANT: Server-side logging uses @gadgets/backend-utils/logger (frontend browser console.* is out of scope):
- Define a package-owned field type and module-scoped logger with a stable dot-separated
componentand, for gatekeepers,vendorId:const logger = createLogger<GitHubLogFields>({ component: "gatekeeper.github", vendorId: VENDOR_ID });. - Emit concrete event names and relevant typed fields, for example:
logger.warn("failed to notify credential expiry", { event: "credentials.expiry.notify.failed", error: err });. Each call emits one indexed object; module/child fields such asvendorIdare inherited. - Use immutable
logger.with(fields)for object-owned or nearby context. Prefer module/object loggers over logger parameters, and do not replace a shallow child logger with ambient context just to remove a local variable. - For bounded operation context needed by deep helpers, independent loggers, or other observability
consumers, use
createObservabilityContextfrom@gadgets/backend-utils/observability-context. Re-establish it per operation; it does not cross RPC, hibernation, or restart, and requiresnodejs_alsornodejs_compat. - Pass caught values as
error. The helper stringifiesErrorinstances and primitives, uses an own stringmessagefor plain objects, omitsundefined, and adds stacks to allErrorlogs. Keep this normalization deliberately small; do not traverse causes or copy arbitrary properties. - Extend field vocabularies locally. Levels:
errorneeds attention,warncontinues best-effort,infois notable lifecycle, anddebugis noisy breadcrumbs. Never log secrets, prompts, headers, tokens, or request/response bodies. - To also dispatch a failure to the optional external issue Reporter (in addition to logging it),
call
reportIssue(failureSite, caught, options?)from@gadgets/backend-utils/error-reporting. Attach ambient fields from the package's observability context and augment them with capture-site fields:reportIssue("overseer.catalog-fallback", err, { handled: true, attributes: { ...obsContext.get(), gatekeeperId } });. It is a no-op when theERROR_REPORTERbinding is absent (local dev / deployments without an issue destination). Only bounded scalars are retained as attributes; reported context obeys the same no-secrets rules as log fields.
IMPORTANT: Frontend error reporting is a separate, opt-in path:
@gadgets/error-reportingowns the vendor-neutral browser/Worker event contract and tolerant, bounded normalization.VITE_FRONTEND_ERROR_REPORTING=trueenables trusted frontend producers and their hidden source maps at build time; deployments without reporting should leave it unset.- The Workshop browser sends best-effort reports to the same-origin
POST /api/client-errorsendpoint. The backend dispatches only when bothFRONTEND_ERROR_REPORTERandFRONTEND_ERROR_RATE_LIMITERare bound; otherwise the endpoint is an intentional no-op. - Gatekeeper management/configurator UIs run as Workshop-owned opaque-origin
srcDocframes. They send bounded reports withpostMessage; the host accepts them only from the known frame window with originnull, adds host-owned surface/vendor context, and performs the same-origin POST. Do not add direct cross-origin reporting from a gatekeeper Worker domain. - Frontend reports never convey authority.
reportedUserIdis supplied by the client and unverified — the name records that it is a report, not a finding — so it is a diagnostic label only and must never be read to make a decision.pageLocationis origin and pathname only, rebuilt bynormalizePageLocationrather than trusted from producers, because a share link's fragment is a bearer capability and anhrefalso retains credentials; non-http(s)URLs are dropped entirely. - Install automatic capture only in trusted first-party surfaces, never gadget/user-authored code. Exception messages and stacks reach the external Reporter, so never intentionally put secrets, prompts, tokens, headers, or request/response bodies in thrown errors or report metadata.