Skip to content

feat: bootstrap gatekeeper-kit leaf modules - #392

Draft
ndisidore wants to merge 8 commits into
mainfrom
nathan/gatekeeper-kit
Draft

feat: bootstrap gatekeeper-kit leaf modules#392
ndisidore wants to merge 8 commits into
mainfrom
nathan/gatekeeper-kit

Conversation

@ndisidore

@ndisidore ndisidore commented Aug 31, 2026

Copy link
Copy Markdown
Member

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.md carries the module-by-module rationale, the implementation plan, and future Layer 2 work for the assembly spec itself

Modules

  • Connect flow: nonce minting, constant-time comparison, and the initiation → OAuth handshake as atomic KV transitions
  • Connect pages: the shared close-window, expired-link, and error pages, with escaping and response headers fixed once
  • Credentials: identity-fenced storage, coalesced skew-aware refresh, and the consumer cache that reports a dead grant
  • Credential expiry: arm-fenced notify-once latch that survives eviction and retries after a failed callback
  • Observers: four verification strategies, a batched-access tracker, and the prepare → authorize → commit gate
  • Actions: two-tier journal, declarative apply/reject dispatch, serialized resolution, one post-apply write
  • Simulation: frozen views over pending records, replay that stops at the first unsupported step, provisional IDs that refuse a conflicting rebind
  • Cursors, cache, serial queue, HTTP errors: RpcTarget cursors with serialized paging, generation-keyed TTL cache, the FIFO gate both need, numeric 401/403/404 classification
@github-actions github-actions Bot added delivery Changes to CI or release delivery gatekeeper Changes to a gatekeeper integration labels Aug 31, 2026
@github-actions

Copy link
Copy Markdown

Preview: pr392-nathan-gatekeeper-kit

https://pr392-nathan-gatekeeper-kit-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

@ask-bonk

This comment was marked as outdated.

@ndisidore ndisidore changed the title Nathan/gatekeeper kit Aug 31, 2026
@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit branch from 2eaa966 to 2f29a87 Compare August 31, 2026 12:14
@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit branch from 2f29a87 to dbf4007 Compare August 31, 2026 14:49
@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit branch 2 times, most recently from dbf4007 to 4344b75 Compare August 31, 2026 21:21
Comment thread packages/gatekeeper-kit/src/credentials.ts
Comment thread packages/gatekeeper-kit/src/observers.ts Outdated
Comment thread packages/gatekeeper-kit/src/action-journal.ts
Comment thread packages/gatekeeper-kit/src/cursors.ts Outdated
Comment thread packages/gatekeeper-kit/src/simulation.ts
Comment thread packages/gatekeeper-kit/src/auth-retry.ts Outdated
Comment thread packages/gatekeeper-kit/src/actions.ts
@ask-bonk

ask-bonk Bot commented Aug 31, 2026

Copy link
Copy Markdown

Submitted 7 actionable inline findings.

github run

@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit branch from 4344b75 to 28ac1c8 Compare August 31, 2026 22:22
@ask-bonk

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.
@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit branch from 28ac1c8 to cda0c21 Compare August 31, 2026 23:37
@ask-bonk

ask-bonk Bot commented Aug 31, 2026

Copy link
Copy Markdown
  1. High observer-tracker.ts:86: generateNonce() runs at module scope, which workerd forbids. Importing the observer module can prevent Worker startup. The token is also isolate-wide rather than per-DO activation, so move it onto each ObserverTracker instance.

  2. Medium action-journal.ts:243: Capacity pruning treats staged records as removable without checking the retained tier. An interrupted retain() can leave staged and retained copies; later pruning calls remove() and deletes the authoritative applied record and revert artifacts.

  3. Medium simulation.ts:166: A conflicting provisional-ID rebind throws an ordinary error, which defineActions() treats as retryable. After a partially successful create, every approval retry can create another provider entity before hitting the same conflict. Classify this conflict as terminal or explicitly integrate it with ActionApplyError.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

delivery Changes to CI or release delivery gatekeeper Changes to a gatekeeper integration

1 participant