Skip to content

Vince/cap 473 workos connector - #351

Merged
cvince merged 24 commits into
mainfrom
vince/cap-473-workos-connector
Sep 10, 2026
Merged

cvince merged 24 commits into
mainfrom
vince/cap-473-workos-connector

Conversation

@cvince

@cvince cvince commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

No description provided.

cvince added 24 commits August 31, 2026 13:00
`capy connect workos` links an existing .env variable to a WorkOS
environment; `capy rotate` mints a replacement key and expires the old
one on a one-hour overlap.

Rotation runs against the dashboard GraphQL API using the session the
WorkOS CLI already holds — `keys` to read, `createKey` to mint,
`expireKey` to schedule the cutover. None of those three appear in the
WorkOS CLI's operation catalog or the WorkOS MCP index, and schema
introspection is disabled on the endpoint, so their shapes were derived
by probing and confirmed against live responses. They can move without
notice; every call branches on a typed `__typename` or an
`extensions.code`, never on message text.

Which key gets rotated is decided by the client ID in .env, not by
position: a WorkOS key belongs to an application inside an environment,
and the client ID is the only thing saying which environment the project
talks to. Both the key and the client ID are found by value shape
(`sk_test_`/`sk_live_`, `client_` + ULID) with the variable name as a
tiebreaker only, so projects that name them something other than
WORKOS_API_KEY / WORKOS_CLIENT_ID still work. Stripe secret keys share
the `sk_test_` prefix exactly, so an ambiguous match asks rather than
guesses.

Token acquisition is cheapest-first: the stored access token, a silent
refresh, then `workos auth login` as a hand-off. WorkOS refresh tokens
are single-use and the CLI does not persist its rotations to the file
Capy reads, so a refresh can strand the CLI's stored token; deferring to
the provider's own login avoids Capy ever writing to another tool's
credential store.

connect records the link and writes no value, per ConnectResult.value.
acquireToken is now the single entry point for both connect and rotate.
Never signed in and signed in but stale end at the same browser; they
were separate before, and the split was the bug — the no-credentials
case printed "run workos auth login" while the expired case ran that
login itself, so a user who had simply never signed in was told to do
by hand the thing the connector could do for them.
Minor bump so a build carrying the WorkOS connector is distinguishable
from a 0.10.0-rc at a glance — `capy --version` is the fastest way to
tell which binary a shell has resolved, and rc.1 and rc.2 were too
close to read quickly.

VERSION and package.json are bumped together; they are two copies of
one fact and a release reads both.
Corrects the previous bump. The connector is not released, so it belongs
in the 0.10.0 release-candidate line rather than opening a new minor —
the repo's convention is rc prereleases leading to a minor (0.9.1, then
0.10.0-rc.1, rc.2), and 0.11.0 jumped that queue.

The bump exists so a build carrying the WorkOS connector is
distinguishable at a glance: `capy --version` is the fastest way to
tell which binary a shell resolved. VERSION and package.json move
together; they are two copies of one fact and a release reads both.
`security find-generic-password -w` returns base64 of the JSON, not the
JSON, so JSON.parse threw on every macOS keychain read. The throw was
swallowed as "no credentials", which surfaced as a successful
`workos auth login` followed immediately by "could not read a usable
WorkOS session" — the store was readable the whole time.

Accepts both encodings, so the file store (plain JSON) and any future
change of mind by the WorkOS CLI both keep working.
Covers the base64 keychain decode fix, which shipped without a bump and
so was indistinguishable from rc.3 at the command line.

`capy --version` is the only cheap way to confirm a rebuild actually
reached the binary a shell resolved — especially with the bun global
symlink, which any `bun add -g` silently restores to the published
package. Every change from here gets its own prerelease bump.
… match misses (0.10.0-rc.5)

Rotation identified the outgoing key by matching .env's value against
displayValue. That is exact when it works and never works in
production: WorkOS reveals a production key's plaintext once, at
creation, so displayValue never matches. Every production rotation
minted a replacement and expired nothing, leaving another live key
behind each time.

Falls back to the newest still-live `capy-rotated-<date>` key in the
same application — a name only this connector mints, so it is a
provenance claim the value lookup cannot make. Second in line on
purpose: the name says Capy created it, not that the app is using it.

Excludes the key just minted, or rotation expires the replacement it is
handing over to. Orders by createdAt (now selected in the keys query)
and falls back to the date in the name, which cannot separate two
rotations on the same day.
…(0.10.0-rc.6)

Shape qualifies a candidate and the name is only a tiebreaker, which
made a typo invisible. A bogus WORKOS_CLIENT_ID was dropped by the shape
filter, so two things went wrong: with a valid client ID in another
variable the run silently retargeted to it and reported success, and
with none the error read "No WorkOS client ID found in .env" while the
variable the user had just edited sat right there.

A variable whose name claims to hold a client ID and whose value is not
one is now named. Fatal when nothing else qualifies, a warning when
something does — a valid value elsewhere does not excuse a broken one
here, because the user edited that variable expecting it to matter.

Shape still decides which variable is used. The change is that a name
disagreeing with its value is said out loud instead of skipped.
…lted (0.10.0-rc.7)

The environment was never a question: it was derived from the client ID
in .env and a mismatch was fatal, on the grounds that rotating into the
wrong environment is silent and expensive. That is still true, so the
disagreement is now said out loud rather than enforced — the client ID
becomes the default and the person who can see both answers.

The application picker replaces a dead end. An environment with several
applications and a key Capy cannot read — which is every production key,
since WorkOS reveals the plaintext once — refused outright and sent the
user to the dashboard. It now asks, naming each application by the keys
living in it, because an app_ id alone is unrecognisable.

Non-interactive behaviour is unchanged in both: a client-ID mismatch is
still fatal and an ambiguous application still refuses. An unattended
rotate must never pick an environment or an application for itself.
Was: the code contains a double-wrapped copy of the org key / it cannot
be decrypted without service co-decryption + authentication.

Now: this code is safe to share with your team member over email or your
team messaging app. It can only be used by them.

Answers the question the sender has instead of describing how the code
is built.
….0-rc.9)

The redeem code is not a bearer credential. redeemCommand.ts derives the
unwrap key from the authenticated session's email, so a code that
reaches the wrong person does not unwrap for them — 'anyone holding it
can join the org until it expires' was false wherever it appeared.

Corrected in seven places across inviteCommand, contract, memberScreens
and initWizardScreen, plus the one test asserting the old wording. The
two remaining 'bearer credential' references are the transport/recovery
code, which really is one.
Was a 7-day default with a 30-day server cap. The redeem code carries a
double-wrapped copy of the organization key and sits in the invitee's
inbox for its whole lifetime, so the window is the exposure: a mailbox
compromised inside it is a compromised org key.

Default equals the ceiling on purpose — there is no lifetime a caller
can ask for that is longer than the one they get by saying nothing.

Flags refuse rather than clamp. Silently shortening --ttl 7d would tell
the caller their invite lives a week while handing them one that dies in
twelve hours, and they would learn otherwise from a teammate who could
not redeem it. CAPY_INVITE_TTL_SECONDS still clamps quietly: it is an
ops and test knob, not somebody asking for something, and shorter values
still win so tests can expire codes quickly.

Covers all four routes to notAfter: --expires, --ttl, the browser's
chosen lifetime, and the env default.
….0-rc.12)

Presets are 30m, 1h, 2h, 4h, 8h, 12h.

A --ttl or --expires past the ceiling now warns on stderr and uses 12h
instead of exiting, so existing scripts keep working.
CAPY_INVITE_TTL_SECONDS clamps the same way; shorter values still win.

Removed the 7d/24h/30d vocabulary from flag help, presets, plan detail,
contract docs and test fixtures. serverCapDays (30) became maxTtlHours
(12).
…rc.13)

An unhandled WorkOSGraphQLError printed a processTicksAndRejections
stack at anyone who ran capy connect workos without access to the
environment.

403 gets its own message naming the signed-in account, the client-ID
variable and its value, and the environment being reached for — the
question in that failure is which of those three does not line up, so
all three are on screen. It points at workos auth status, since the
usual cause is the wrong account.

Anything else is one line with the status and no stack.

Branches on exception.status, not extensions.code: WorkOS labels these
INTERNAL_SERVER_ERROR and puts the real 403 in the nested status, so the
code is not the discriminator it looks like.
The two are one link: an API key means nothing without the client ID
saying which environment it belongs to, and marking only the key left
the other half looking untracked.

Only on an unambiguous match. If the user had to pick between
candidates, marking their answer would promote a disambiguation into a
recorded fact, and nobody reading keep.lock later could tell the two
apart.

ConnectResult gains an optional `also` list and writeAndSync an
`alsoConnect` option, so a provider can mark more than one variable in
the same write. Both paths — local-only and push — attach through one
helper.
changed_at means "when this value last changed". The deploy path was
writing it for two reasons, and the second was a lie:

buildDeployKeep stamped it client-side. The service owns that field — it
derives it by diffing against stored state and discards whatever a
client sends — but the deploy path writes keep.lock straight into a git
worktree and never goes through the service, so these timestamps had no
authority behind them. They then met server-assigned ones on merge, over
identical value_hash, and conflicted for no reason.

touchDeployKeep bumped it on --force to manufacture a diff for CI, on
values that had not changed at all. That claim was false, and everything
downstream inherited it: the UPDATED column, relative-time copy, and
every hand-resolved keep.lock conflict this week.

--force now bumps a top-level deploy_revision counter, which says the
true thing: this lockfile has been deployed N times. serializeKeep
carries it (its top-level whitelist was dropping unknown fields) and
emits it only once set, so existing lockfiles stay byte-identical.
computeKeepHash covers only key:resource_id:value_hash per variable, so
it cannot perturb client/server hash agreement.
capy escalated every linking failure to `vercel link`, whose wizard
offers to pull the project's environment variables into .env.local.
capy owns those, so the pull mixes sources of truth — and most of the
failures it escalated could not have been fixed by that wizard anyway.

linkByProjectPicker now reports why it stopped instead of a bare false:

  no-token    -> refuse, point at `vercel login`. `vercel link` needs a
                 credential too, so escalating solved nothing.
  api-error   -> refuse with the underlying error. Same API `vercel link`
                 would call, usually transient.
  no-projects -> `vercel link`. Creating a project is the one thing the
                 picker cannot do.
  declined    -> `vercel link`. The user asked for the wizard.

Two of four paths no longer risk the env pull.

The warning before the wizard is softened to "if it offers": the prompt
is absent from vercel 53.1.0's bundle, so telling users it is coming
makes them hesitate over a question that may never appear.
capy shelled out to a bare `gh` and detected it with a bare `which gh`,
so both the check and the call rode the same PATH. Homebrew installs to
/opt/homebrew/bin, which is absent from the environment of anything not
launched from a login shell — so capy reported "gh CLI not installed"
about a gh sitting right there, sending the user to reinstall something
they already had.

resolveGh() consults PATH first, so a custom install or version manager
still wins, then falls back to the standard install locations. Every
bare `gh` spawn across deploy/git.ts and githubActionsConnector.ts now
goes through it, and the not-found message names what was searched.

Reproduced with PATH=/usr/bin:/bin: `which gh` finds nothing, resolveGh
returns /opt/homebrew/bin/gh.
….18)

An invalid or missing WORKOS_CLIENT_ID exited. But the environments are
one API call away and capy fetches them moments later anyway, so it can
show the real ones and ask which project this is. Refusing while holding
the answer is the worse product.

chooseClientId returns null instead of exiting; resolveTarget carries an
absent client ID through, and the environment picker — which already
handled "matches nothing" — now also handles "nothing to match with".
Both end at the same question.

The pick is used for this run only and never written back to .env, and
it is recorded as ambiguous so WORKOS_CLIENT_ID is not marked managed
off the back of it: nothing in .env said this, the user did.

Non-interactive still refuses. There is nobody to ask, and choosing an
environment unattended is the mistake the client ID exists to prevent.
The environment picker asked on every interactive run, including when
keep.lock already carried the answer on the connector entry. The picker
is for the first connect, when nothing knows which environment this
project is; asking again afterwards reads as capy having forgotten what
the user already told it.

connect passes the recorded account_id via findManagedConnector, rotate
passes previous.account_id. A recorded id that no longer resolves falls
through to the picker — the environment was deleted or this is a
different WorkOS account, and both are worth stopping for.

When .env's client ID points somewhere else, the recorded environment
still wins but the divergence is printed: the client ID is what the
running app uses, so it is worth one line.
connect marks the client-ID variable managed so the pair is tracked
together, and anything managed lands in listManagedKeys — which is what
capy rotate and --all build their targets from. So rotate reached the
client ID, minted an API key, and wrote it over the environment
identifier the app depends on. Seen in the wild as a client-ID connector
entry carrying key_prefix 'sk_test_' and an sk_ fingerprint that did not
match the key's own.

rotate now refuses when the variable holds a client_ value. Checked by
value shape, not variable name — the name is a convention, the value is
a fact, which is why the connector finds these variables by shape to
begin with.

This is the cost of 'managed and rotatable'. Marking it managed is what
makes the pair legible in capy list; being rotatable was never
meaningful, since WorkOS issues no replacement client ID.
….0-rc.21)

rc.20 refused to rotate a variable whose value looked like a client ID.
That guards a healthy variable and not a damaged one: once rotate has
stuffed an API key into WORKOS_CLIENT_ID, the value no longer looks like
a client ID and the next rotate stuffs it again.

connect now records source: 'client-id' on that entry — the field is
documented as provider-specific — and rotate refuses on it. Unlike a
shape test it survives the value being overwritten, and it names the
likely cause when the two disagree.

The shape test stays as a second signal, for entries written before this
marker existed.
@cvince
cvince merged commit 32c7d60 into main Sep 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant