feat: bootstrap gatekeeper-kit leaf modules - #392
Conversation
Preview:
|
This comment was marked as outdated.
This comment was marked as outdated.
2eaa966 to
2f29a87
Compare
This comment was marked as outdated.
This comment was marked as outdated.
2f29a87 to
dbf4007
Compare
This comment was marked as outdated.
This comment was marked as outdated.
dbf4007 to
4344b75
Compare
|
Submitted 7 actionable inline findings. |
4344b75 to
28ac1c8
Compare
This comment was marked as outdated.
This comment was marked as outdated.
New workspace library @gadgets/gatekeeper-kit holding the provider-independent pieces every gatekeeper hand-rolls today. This first cut lands the connect-flow leaves: nonce primitives, the two-stage handshake, the shared connect pages, the HTTP no-access classifier, the simulation substrate, and the expiry latch. The latch differs from its prior art: it is set only after the callback RPC resolves, so a crash mid-notify re-notifies later instead of silencing every future expiry, and concurrent callers share one in-flight notification. Tests run as two vitest projects: Node for pure logic, workerd for the modules that need crypto.subtle.timingSafeEqual.
Four strategies cover the taxonomy every gatekeeper picks from -- private, ACL, tracked-set, open -- behind one ObserverStrategy seam, plus ObservationGate, which folds a strategy's exclusions into the approval-queue authorization and promotes newly-revealed sets only once the overseer has agreed to hide them. ObserverTracker extends the version it came from in the two ways real gatekeepers need: the observer and observed-set key families are named separately (so a port keeps both of its existing prefixes), and the ACL oracle is batched -- one provider call per admission round rather than per set. A short or ragged oracle result denies. legacyObservedValue honors a stored `true` as "observed", and denyMessage names the failing set.
CredentialCoordinator owns the account DO's credential record: skew-aware refresh, concurrent callers coalesced onto one provider round-trip, and an identity fence so a reconnect or revoke landing mid-refresh is never clobbered by the older refresh's result. CredentialsExpiredError is the one signal that means the grant itself died; anything else propagates with credentials intact. CredentialSource is the facet/verifier side: a short instance-local cache over the account DO, and the single place a provider auth failure becomes an expiry notification.
ActionJournal owns the queued-action record: sequential ids, the staged -> pending lifecycle, and listPending() as the input createSimulationView expects. It is two-tier -- a retained applied record moves out of the pending prefix to a sibling one, so the pending scan stays bounded by genuinely pending records however many applied ones accumulate (the shape github already uses). Lookups check both tiers and never filter by state: the output gate commits the staged record before the submitAction RPC can leave, so a record still marked staged may already be pending for the overseer. Retiring the retained tier is consumer policy -- retention is unbounded and caps are per-vendor. defineActions turns per-kind apply/reject handlers into the overseer's callbacks. An apply returns its apply-time artifacts, which the kit persists in one write together with the state transition, so handlers never touch the journal mid-apply and there is exactly one writer. An apply that throws leaves the record for a retry and reports "failed" -- a partial provider effect is when caches are most stale. afterResolve fires once per resolution because every gatekeeper invalidates caches after one and the big ones repeat it per branch, where a forgotten branch is a silent stale read. Revert is deliberately absent. Reject's variance lives inside a handler body, which dispatch absorbs; revert's variance lives in record lifecycle, which it cannot -- five gatekeepers have five incompatible revert/retention behaviours, so revert belongs at the facet seam as ordinary consumer TypeScript. Apply is at-least-once: the provider call can succeed and the process crash before the journal write, and the overseer's retry re-applies. No gatekeeper solves this today; the kit documents it rather than pretending otherwise. SerialTaskQueue and displayReason come from the applyActionsThrough work, so the batch contract can layer onto this later without reshaping the journal.
KvTtlCache holds stable provider metadata in the facet's own storage, keyed within a generation the caller bumps when an applied action may have invalidated everything at once -- cheaper and more complete than tracking which entries a write touched.
StreamingCursor fetches provider pages lazily, overlays simulation onto each item, and merges simulation-only items at their sort position, so a resource with a long history returns its first page without reading all of them. Pages emptied by the filter keep it fetching rather than reporting the end early. Both extend RpcTarget undecorated; a consumer subclasses and decorates when it wants validated cursor calls.
Deployable discovery keys on wrangler.jsonc alone (readDeployablePackages), not on the package name, so gatekeeper-kit is already invisible to the release pipeline, the dev server and preview configs. AGENTS.md said otherwise -- "Each gatekeeper runs as a separate Cloudflare Worker" -- which misleads a reader into treating the kit as deployable, or into adding a wrangler.jsonc to make it "work". Doing that would make it deployable and, because workerKind classifies by prefix, a gatekeeper: shortName "kit", a routed BASE_URL, and -- since it is absent from NO_DEFAULT_CRED_INPUTS -- a wizard demanding CLIENT_ID and CLIENT_SECRET before anyone could install the instance. CI already caught that, but only as three "missing fixture bundle" failures that name the symptom rather than the cause, so pin the invariant where it can say so directly. The internal repo's gatekeeper-shared is the same shape, so this documents an existing convention rather than inventing one.
Records the design behind the Layer 1 modules that just landed, in the
docs/ directory alongside the other design docs. The plan drove the
implementation, so committing it puts the reasoning next to the code
rather than leaving it in a scratch file.
Reconciled against the shipped signatures before committing, since a
tracked doc reads as authoritative where an untracked one does not:
* 4.6 credentials: the upgrade() contract returns
{ credentials, legacyKeys } and reads only -- the coordinator performs
the deletes. Documents the write order in commit()/clear() and why the
fence goes first.
* 4.7 observers: aclObservers takes hasAccess() (answering, and only a
literal true admits) rather than a throwing verify(); adds the
canonicalSetId, maxTrackedSets and concurrency options, and
observerIds() on the tracker and strategy.
* 4.9 simulation: isProvisional is a constructor option, not a method.
Corrects the binding key to `${namespace}prov:${id}`.
* 4.10 cache: the API is cached(key, ttlMs, load); there is no public
get/put pair, and the generation fence is internal rather than the
caller's obligation.
Also adds a status header (Layer 1 landed, Layer 2 still proposal) and
the 4.8 key-layout tables a port needs: the counter convention is
next-unused in 9 of 12 gatekeepers but last-issued in github, linear and
spotify, where adopting the existing counter key would re-issue the last
ID; and the retained tier's derived prefix matches no gatekeeper's
existing keys.
28ac1c8 to
cda0c21
Compare
|
Adds
@gadgets/gatekeeper-kit: fourteen standalone leaf modules that factor out the plumbing every gatekeeper re-implements around its service-specific parts. The package is a library and each module takes the narrowest KV surface it needs and works à la carte, with no dependency on the assembly layer that will sit on top.docs/gatekeeper-kit.mdcarries the module-by-module rationale, the implementation plan, and future Layer 2 work for the assembly spec itselfModules