Skip to content

feat: bootstrap gatekeeper-kit leaf modules - #392

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

feat: bootstrap gatekeeper-kit leaf modules#392
ndisidore wants to merge 9 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 feat: bootstrap gatekeeper-kit leaf modules 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.

@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit branch from 28ac1c8 to cda0c21 Compare August 31, 2026 23:37
@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit branch from cda0c21 to 2e81580 Compare September 1, 2026 11:48
Comment thread packages/gatekeeper-kit/src/observer-tracker.ts
Comment thread packages/gatekeeper-kit/src/credentials.ts Outdated
Comment thread packages/gatekeeper-kit/src/cursors.ts Outdated
@ask-bonk

ask-bonk Bot commented Sep 1, 2026

Copy link
Copy Markdown

Submitted 3 actionable inline findings. Tests were not run because pnpm is unavailable.

github run

@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit branch from 2e81580 to 8cf8a2f Compare September 1, 2026 15:28
@ask-bonk

ask-bonk Bot commented Sep 1, 2026

Copy link
Copy Markdown

@ndisidore Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.


/** A `Set-Cookie` value binding the OAuth callback to the browser that began the redirect. */
export function oauthBrowserCookie(nonce: string): string {
return `${requireOAuthCookieName(nonce)}=1; Path=/; Max-Age=${OAUTH_COOKIE_MAX_AGE}; ${OAUTH_COOKIE_SECURITY}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: This does not actually bind the callback to the initiating browser. The cookie name is derived from the OAuth nonce exposed as state, and its value is always 1, so a recipient of the copied provider URL can obtain the callback code/state and send it with the computable Cookie header. That links the recipient provider identity to the initiating Workshop account despite the protection described in the design doc. Persist an independent random cookie secret with the OAuth-stage record and verify that secret at callback time.

*/
constructor(kv: CacheKv, authorityId: string) {
this.#kv = kv;
this.#authority = authorityId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Freezing the authority for the lifetime of this cache does not provide the claimed reconnect partitioning. If an account reconnects from principal A to B while the facet/session remains alive, line 58 can still serve an A entry; after a miss, a loader using current B credentials is instead stamped as A. The separate-instance test cannot exercise this. Make authority an input to each load, and include it in the in-flight-load key, or otherwise rotate the live cache when credential identity changes.

// TypeScript cannot correlate a tagged union's payload with its definition, so the dispatcher
// is the one place that erases the payload type.
const definitionFor = (entry: TaggedAction<M>) =>
definitions[entry.kind] as ActionDefinition<unknown, Host> | undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Indexing a normal object accepts inherited prototype names as definitions. A stored stale kind such as constructor resolves to Object; its inherited apply function can return successfully, after which this action is removed as applied without any provider handler running. toString has the same issue. Require an own property before returning the definition so these records take the intended unsupported-kind path.

const state = this.#coerce(raw)?.state;
if (state === undefined) continue;
const id = this.#idFrom(key);
if (state === "staged" || state === "failed") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: A terminal apply failure still has a pending Workshop action until the user rejects it, but this pruning removes the only stable failure record for that action. A later approval becomes Unknown pending action, and rejection succeeds as if there were no diagnostic, potentially hiding an ActionApplyError warning about partial external effects. Failed records are user-clearable through reject, so count them toward the bounded unresolved set rather than deleting records the Workshop still exposes.

*/
describe(payload: Payload, host: Host): ActionPresentation | Promise<ActionPresentation>;
/** Provisional references this payload creates, for actions a later one can depend on. */
provides?(payload: Payload): Iterable<string>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: string itself satisfies Iterable<string>, so the natural and type-correct provides: payload => payload.ref is spread into individual characters at line 311. A dependency on ~12 is then not cascaded, while unrelated references sharing a character can be cascaded incorrectly. Exclude a bare string from this callback type or normalize it as one reference; the same applies to dependsOn.

*/
export function createSimulationView<R extends SimulationRecord<unknown>, Target>(
records: readonly R[],
targets: (action: R["action"]) => Iterable<Target>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: When Target is string, a type-correct extractor such as action => action.target returns an iterable and this loop indexes each character instead of the target. forTarget("page-123") then silently omits its pending actions, making simulated reads stale. Require a collection type that excludes bare strings or explicitly treat a string result as one target.

@ask-bonk

ask-bonk Bot commented Sep 1, 2026

Copy link
Copy Markdown

Submitted 6 actionable inline findings.

github run

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 6677e99 to 7b6838a Compare September 1, 2026 16:50
try {
await queue.submitAction(id, description);
} catch (error) {
journal.rollbackSubmission(id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: An RPC rejection does not prove that submitAction() failed before committing. If the Overseer stores the action and its response is then lost, this rollback deletes the gatekeeper record while the Workshop retains a pending action. Approval subsequently fails with Unknown pending action, and a retry submits a different ID. Preserve/reconcile the staged record on an ambiguous RPC failure rather than assuming rejection means non-delivery.

return {
submit: async (queue, kind, payload) => {
const definition = definitions[kind];
const { title, description, implementsRevert } = await definition.describe(payload, host);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: The payload is described before the journal snapshots it. Because describe() may await and Payload is mutable, it can derive text from one value while the caller or hook mutates that same object before stageAction() stores it, leaving the approver text different from what apply() sends. Snapshot the payload first and describe the exact stored snapshot, as the API contract promises.

const current = this.#connected();
const expiresAt = this.#options.expiresAt?.(current);
const skew = this.#options.refreshSkewMs ?? ACCESS_TOKEN_SAFETY_MS;
if (expiresAt === undefined || Date.now() < expiresAt - skew) return current;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: expiresAt() is documented to return a finite epoch, but that is not enforced here. Provider JSON such as 1e400 parses to Infinity; this comparison then treats the access token as permanently fresh, so the refresh grant is never used and eventual auth failure can incorrectly retire a recoverable connection. Reject a non-finite expiry instead of treating it as non-expiring (undefined already represents that case).

const nonceKey = `${OBSERVER_NONCE_PREFIX}${id}`;
const nonce = generateNonce();
// Both writes before the first await, so no read can observe the attempt without its nonce.
kv.put(attemptKey, verifier);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: This admission attempt can become permanent if the activation dies after the write, or if this write succeeds and the nonce write throws. The Overseer has not persisted observerId yet, so its next open generates a different ID and cannot rotate/remove this orphan. Orphans are enumerated as active observers and count toward maxObservers; enough interrupted admissions permanently block new collaborators. Make attempts reclaimable rather than relying on a retry of the same ID.

response: Response, maxBytes: number = MAX_RESPONSE_BYTES,
): Promise<string> {
const advertised = Number(response.headers.get("content-length"));
if (Number.isFinite(advertised) && advertised > maxBytes) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: maxBytes is never validated. Passing NaN or Infinity makes both this check and the running-total check false for every body size, silently disabling the memory cap that this helper is meant to enforce. Reject non-finite/invalid limits (the package already has requirePositiveInt) before reading the response.

@ask-bonk

ask-bonk Bot commented Sep 1, 2026

Copy link
Copy Markdown

Submitted 5 actionable inline findings.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant