Skip to content

feat(cli): void init --github — scaffold GitHub Actions OIDC deploy workflow - #217

Merged
Brooooooklyn merged 7 commits into
mainfrom
feat/deploy-on-github
Jun 22, 2026
Merged

Brooooooklyn merged 7 commits into
mainfrom
feat/deploy-on-github

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Jun 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds void init --github, the CLI side of Deploy on GitHub. It scaffolds .github/workflows/void-deploy.yml, which:

  • requests a GitHub OIDC token (audience void) — permissions: id-token: write, no long-lived secret stored in the repo
  • exchanges it at POST $VOID_API_URL/auth/github-oidc for a short-lived, project-scoped Void deploy token
  • runs void deploy on the GitHub-hosted runner

On a git push, GitHub Actions builds and deploys via OIDC. The platform side (GitHub App, webhooks, OIDC exchange endpoint, builds + container executor) lives in the companion void-platform PR.

Changes

  • --github flag on void init (workflow-only scaffold; safe to re-run, skips if the workflow already exists)
  • void init --github CLI help + examples entry
  • scaffolded workflow uses VOID_API_URL repo var (defaults to https://api.void.cloud) and per-(repo, ref) concurrency
  • docs: docs/guide/deployment.md + docs/reference/cli.md
  • init.test.ts coverage for the new flag + scaffold

Test

  • init.test.ts: 129 passed
  • fmt / lint / typecheck: clean

🤖 Generated with Claude Code


Note

Medium Risk
Changes deploy credential handling in child processes and CI auth patterns; routing publish semantics affect whether users see success vs degraded reachability without failing the command.

Overview
GitHub Actions deploy moves from a long-lived VOID_TOKEN secret and deploy.yml to void init --github writing .github/workflows/void-deploy.yml: id-token: write, repo/ref concurrency, OIDC exchange at POST $VOID_API_URL/auth/github-oidc, then void deploy --project. The workflow bakes in the linked slug from .void/project.json when present, otherwise vars.VOID_PROJECT; interactive init patches the placeholder after project linking. Docs and CLI help describe OIDC and staging via VOID_API_URL.

void deploy adds buildEnv() so CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET are removed from child env for vite build, git metadata, and drizzle-kit (parent process.env unchanged for interactive re-auth). Deploy done events may include published: false; the CLI still treats the deploy as success but shows degraded spinner text, logs deploy_degraded, and warnUnpublishedRouting after the summary.

Unit tests cover OIDC workflow scaffolding, slug patching, buildEnv, unpublished routing, and PlatformClient Access header capture.

Reviewed by Cursor Bugbot for commit af19690. Bugbot is set up for automated code reviews on this repo. Configure here.

…ploy workflow

Scaffolds `.github/workflows/void-deploy.yml`, which mints a GitHub OIDC token (audience `void`), exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived scoped deploy token, and runs `void deploy` — no long-lived secret stored in the repo. Adds the `--github` flag (workflow-only init), CLI help entry, deployment guide, and CLI reference updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Jun 9, 2026 •

Copy link
Copy Markdown

❌ Deploy Preview for staging-void-cloud failed.

Name Link
🔨 Latest commit 51518c4
🔍 Latest deploy log https://app.netlify.com/projects/staging-void-cloud/deploys/6a2fd85deb9f6d0008929723

Comment thread packages/void/src/cli/init.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c7c057d2d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/cli/init.ts Outdated
Brooooooklyn and others added 2 commits June 9, 2026 12:59
The deploy `done` event now carries an optional `published` flag. When the
server reports `published: false` (routing KV not flipped after the go-live
DB commit), the CLI degrades its messaging instead of printing a clean
"Deployed!", logs `deploy_degraded` rather than `deploy_success`, and emits
a persistent warning in the deploy summary directing the user to re-run
`void deploy`. Additive and backward-compatible: older servers omit the
field, so the deploy reports success exactly as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`void deploy` constructs the PlatformClient (which captures the CF Access
headers once at construction and reuses them for every request), then later
spawns untrusted `vite build` / framework preset builds with the inherited
process.env. Without scrubbing, a shared, long-lived CF Access service token
would leak to arbitrary repo build scripts and plugins.

Delete the CF_ACCESS_CLIENT_ID/SECRET pair from process.env immediately after
the client is constructed: the creds are needed only by the CLI's own API
calls, never by the build, so every later request stays authenticated while
user code can no longer read them. Only the service-token pair is scrubbed;
the cloudflared CF_ACCESS_TOKEN JWT (dev path) is left untouched.

Regression tests: the client keeps sending CF-Access headers after the env is
scrubbed (capture-at-construction invariant), and runDeploy deletes the pair
while preserving CF_ACCESS_TOKEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ad1d919132

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/cli/deploy.ts Outdated
…orkflow

`pnpm install` and `bun install` could resolve dependency state not
represented by the pushed commit, undermining reproducible deploy-on-push.
npm (`npm ci`) and yarn (`--immutable`) were already frozen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/void/src/cli/deploy.ts Outdated
…ion, CF Access re-auth

- init: when a project is linked during the same interactive `void init`, bake
  the resolved slug into void-deploy.yml (Step 8 scaffolds before Step 9 links).
  Patch the placeholder after linking instead of reordering the prompts (which
  would drop GitHub setup on cancel and break confirm-order assertions).
- init: migrate a legacy token-based .github/workflows/deploy.yml (void@0.9.x)
  to the OIDC void-deploy.yml in place on upgrade, instead of writing a second
  workflow that would double-deploy.
- deploy: stop mutating process.env to scrub the CF Access service-token pair —
  it broke interactive expired-token re-auth (cfAccessHeaders re-reads env after
  the scrub). Pass a sanitized child env (buildEnv) to every untrusted
  build / drizzle-kit / git spawn instead, keeping both the live client and
  re-auth working while denying the token to user code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: baa8eddd78

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/cli/init.ts Outdated
Comment thread packages/void/src/cli/init.ts
Per product decision, `void init --github` no longer detects, deletes, or
migrates a pre-existing `.github/workflows/deploy.yml`. It only writes
`void-deploy.yml` (skipping with a warning if that file already exists) and
leaves any other workflow untouched.

This removes the legacy-`deploy.yml` migration added earlier in this PR — which
could have deleted an unrelated user workflow that merely shared the filename
(Codex P2). Migrating workflows in place was the wrong default; a user upgrading
from the old token-based workflow removes the stale `deploy.yml` themselves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 51518c4. Configure here.

Comment thread packages/void/src/cli/init.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 51518c4079

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/cli/init.ts
Comment thread packages/void/src/cli/init.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af19690f98

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/cli/init.ts
@Brooooooklyn
Brooooooklyn merged commit 94b0d4e into main Jun 22, 2026
7 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/deploy-on-github branch June 22, 2026 06:08
cpojer pushed a commit that referenced this pull request Sep 11, 2026
…s) (#217)

* docs(design): org-shared GitHub App installations (RFC 0074 follow-up)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): github_installation_members table + migration 0030 (Task 1/13)

Additive schema foundation for org-shared GitHub App installations. Numeric
installation_id (text, no FK, mirrors github_connections); nullable
github_account_id; user_id cascade. Test clearTables wired. No prod code
references the table yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): github_installation_members db helpers + TTL (Task 2/13)

upsert/list/delete membership helpers keyed on (installation_id,user_id),
active-membership TTL predicate (DB-clock now()), and exported
getGitHubAccountIdForUser reverse lookup. MEMBERSHIP_TTL_SECONDS=24h. Additive;
deploy-path resolvers untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): installations listing returns owner OR active member (Task 3/13)

getGitHubInstallationsByUser now ORs in an EXISTS over github_installation_members
correlated on the numeric installation_id with a TTL check, so shared org installs
appear for members. Route unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): install callback grants membership on proven user-token path (Task 4/13)

When a different Void user completes the install-callback and authResult.authorized
is true (GET /user/installations proved they can manage this org install), grant a
github_installation_members row instead of 403 — without re-owning the install. The
account-id fallback path keeps the 403. Owner success redirect mirrored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): user-installations discovery + per-repo verify helpers (Task 5/13)

Export listAuthorizedInstallationIds (GET /user/installations) and verifyUserRepoAccess
(GET /user/installations/:id/repositories — repositoryId null = confused-deputy reject).
Both fail-closed via isAppUserOAuthConfigured; redirect_uri threaded through the token
exchange. authorizeInstallViaUserToken gains an optional redirectUri; install callback
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): JOIN routes for org-shared installs (Task 6/13)

/github/join-url + /github/join-callback + POST /github/join/complete — deferred-bind
PKCE+HMAC flow cloning RFC-0074 link routes, no token persisted. Discovery DX: the
callback lists all accessible installs and mints a join_code for the Void-known subset;
/complete binds membership on the authenticated bearer holder (Threat L1). Frozen CLI
contract: join_code param + { status:'joined', count }.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): connect-authorize routes for non-owner per-repo grant (Task 7/13)

Add GET /github/connect-authorize-url (authMiddleware) and
GET /github/connect-authorize-callback (public) — the deferred-bind,
PKCE-bound authorize round-trip that lets a non-owner org member prove
per-repo access before connecting a project. The callback verifies the
target repo against the CONNECTING user's own token
(verifyUserRepoAccess) and mints a short-lived signed connect_grant the
CLI redeems at connect time (Task 8). Confused-deputy close: no grant is
minted unless both the repositoryId and the caller's github account id
are proven; the public callback binds no membership (Threat L1 — identity
binds only under an authenticated request). Clones the audited join flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): non-owner member connect via per-repo grant (Task 8/13)

Extend POST /:id/github so a non-owner org member can connect a project.
Owner path is behaviorally unchanged. A non-owner must be an active
installation member (else the same opaque 404) and must present the
connect_grant minted by /github/connect-authorize-callback plus its PKCE
verifier. The grant is verified for purpose/exp/install/repo match, PKCE
(S256), and — Threat L1 — that the GitHub identity that proved per-repo
access equals the redeeming user's own linked GitHub id. On success the
member path trusts grant.repositoryId and SKIPS the installation-token
resolveRepositoryId — the confused-deputy close. Stable codes
connect_authorization_required / invalid connect grant drive the CLI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): revocation db helpers — installs-by-account + guarded clear-owner (Task 9/13)

Add getGitHubInstallationsByAccount (scoped by account_id + account_type)
and clearGitHubInstallationOwnerForUser (guarded UPDATE nulling the owner
pointer only where user_id still matches the removed user; empty-list
no-op; count via updatedRowCount for pg/PGlite portability). Consumed by
the org-removal revocation webhook (Task 10).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): organization/member_removed revocation webhook (Task 10/13)

Handle the GitHub organization webhook: on member_removed, map the
removed GitHub identity to a Void user and, scoped by (account_id,
'Organization') installs ∩ that user's projects, atomically terminalize
their in-flight build tokens (terminateBuildsForProject), delete their
connections, delete their membership cache rows, and clear any owner
pointers they held — under a single-table FOR UPDATE lock on the
connection rows (serializes an in-flight OIDC exchange), with the DO
cancel awaited after commit. Idempotent; only member_removed acts. This
is the "removed member still auto-deploys" close, since the deploy path
is ownership-independent. TTL on the membership row remains the backstop
if the event is never delivered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): monotonic revocation — tombstone + iat close stale-artifact replay (Task 14/13)

Adversarial review found revocation was not monotonic: member_removed
hard-deleted with no tombstone and join_code/connect_grant are stateless
HMAC bearers, so a removed member could replay pre-minted artifacts to
recreate a connection that then auto-deploys forever (deploy path is
ownership-independent).

Add a durable github_installation_revocations tombstone (per
installation+user, revoked_at high-water mark, timestamptz for
tz-correct epoch-ms comparison) written by member_removed, and stamp each
join_code/connect_grant with an iat. Redemption rejects any artifact whose
iat is at/before the tombstone (a legacy artifact with no iat is treated
as iat 0 → fail closed); a fresh artifact minted after a legit re-add is
allowed. The connect gate's go-live runs inside a transaction that
FOR UPDATE-locks the installation row that member_removed also locks
(deterministic order), closing the in-flight connect-vs-revocation race.
Push/OIDC unchanged — they can only consume connections, which can no
longer be created or resurrected post-revocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): close stale-owner reconnect race in connect gate (Codex F2)

Re-read the installation user_id under the go-live FOR UPDATE lock and
re-derive ownership, instead of trusting the pre-transaction isOwner. If
member_removed clears the owner pointer while a connect request waits on
the install-row lock, the stale isOwner=true previously skipped the
membership+tombstone recheck and inserted an auto-deploying connection for
a just-removed owner. Now the owner->cleared transition fails closed (404)
and a vanished install row (concurrent installation.deleted) 404s without
crashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): unify webhook lock order to install-first (Codex F6, AB-BA deadlock)

member_removed locks the install row before connection rows (required to
close the connect-gate phantom-INSERT race). The pre-existing installation
deleted/suspend and installation_repositories-removed handlers locked in the
opposite order (connections first), so concurrent same-install webhooks could
AB-BA deadlock. Reorder both teardown handlers to lock the install row first,
then connections — a behavior-preserving lock-order change (same rows, same
status flips, same terminate fan-out). No build-insert/OIDC/push path locks
github_installations, so install-first is globally consistent with no new cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): make revocation tombstone a sound single-clock high-water mark (Codex F4)

F4a: mint artifact iat from the DB clock (getDbNowMs) at both mint sites so the
iat<=revoked_at revocation check no longer crosses clock domains (Worker Date.now()
vs DB now()); exp stays Worker-clock (compared to Date.now() at redemption).
F4b: recordInstallationRevocations conflict update now takes greatest(existing,
excluded) so the tombstone high-water mark can never regress under redelivery
reordering (Postgres now() is transaction-start time). Fail-closed 0 fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): close membership-survival gaps in revocation (Codex F1 + F5)

F1: member_removed now resolves affected Void users by the UNION of oauth
linkage AND github_installation_members.github_account_id, so a bearer whose
membership was proven by the removed GitHub id but who is not oauth-linked to
it is also severed + tombstoned. Closes a narrow deploy path: V joins via id
X's proof without linking X, member_removed(X) previously early-returned (no
tombstone), V links X later and replays a stale grant. Single-user behavior
is preserved exactly (union == [removedUser.id]).

F5: join/complete's tombstone-read + membership upsert now runs in a
transaction that FOR UPDATE-locks the target install rows install-first
(matching member_removed + the connect gate), so a concurrent member_removed
can no longer race-restore a membership it just revoked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): re-key revocation tombstone to github_account_id (Codex round-3, unresolved-future-user replay)

A soon-removed org member could pre-mint a join_code + connect_grant (public
callbacks, carrying githubAccountId=X, no Void state) while still in the org.
The tombstone was keyed by user_id, so member_removed(X) — finding no oauth
link and no membership row — recorded NOTHING, and after removal the attacker
completed the stale join, linked X, and redeemed the stale grant into an
auto-deploying connection. The durable identity the artifacts carry is the
github_account_id, always known to member_removed from the webhook payload.

Re-key github_installation_revocations to (installation_id, github_account_id)
(no users FK — a GitHub id is not a Void user id; the tombstone must outlive
user deletion). member_removed now records (each org install, X) UNCONDITIONALLY
inside the install-first lock, even when no Void user resolves; join/complete
checks joinCode.githubAccountId and the connect gate checks grant.githubAccountId
(== the caller's linked id via Threat-L1). Preserves F2/F4/F5/F6 and the F1
sever. Regenerated the undeployed 0031 migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): resolve + sever under the install lock in member_removed (Codex R4-1, critical)

member_removed resolved the affected-user set (removedUser + membership-proof
users + union) and thus the entire sever set BEFORE the transaction took the
install-row lock. A stale-artifact redemption that committed in that window
inserted an auto-deploying github_connections row the empty sever set never
caught (the tombstone-after only blocks future redemptions). Move all three
resolution reads inside the tx, after the lock + unconditional tombstone,
using txDb, so a redemption that commits first is seen and severed by the
post-lock re-read (via the new oauth link OR the membership-proof-id row).
The membership hard-delete + owner-pointer clear also now operate on the
post-lock set (Codex named only the connection scan; the clear was a sibling gap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): stamp artifact iat before the GitHub proof (Codex R4-2)

join_code and connect_grant stamped iat = getDbNowMs() AFTER the GitHub
access proof, so iat was later than the proof. A removal landing in the gap
(dominated by GitHub access-propagation lag, which can let a just-removed
member's proof still succeed for seconds) left revoked_at < iat, treating a
pre-removal proof as fresh. Stamp iat BEFORE the proof at both callbacks so
it is a conservative lower bound: any removal at/after the proof has
revoked_at >= iat and the artifact is rejected (fail-closed). Re-join after
re-add is unaffected (fresh iat > the old revoked_at). exp stays Worker-clock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): gate the install callback's owner-adopt + membership backstop on the tombstone (Codex R4-3)

The install callback had two org-shared grant points — owner re-adopt
(upsertGitHubInstallation adopt-guard on a cleared owner) and the Task-4
membership backstop — with no install-row lock and no revocation check, so a
removed installer whose LIVE proof succeeded during GitHub propagation lag,
racing member_removed, could be re-adopted as owner (owner connect then
bypasses the tombstone) or re-granted membership. Wrap the mutation section in
an install-first-locked transaction, stamp a pre-proof iat, and gate both
grants on getInstallationRevocationAt(install, provenGithubId) >= iat. Closes
the concurrent interleaving; the durable re-auth-during-lag case is left as a
documented, live-proof-bounded accepted residual (iat cannot distinguish it
from a legitimate re-add). No tombstone check added to the owner connect path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): round-5 hardening — clock_timestamp tombstone + null-account join guard

R5-2: recordInstallationRevocations stamped revoked_at with now() (Postgres
tx-START time). member_removed's tx BEGINs, then waits on the org install
FOR UPDATE lock, then writes the tombstone — so now() froze before the
lock-wait, and an artifact minted during that window carried iat > revoked_at
and replayed after commit. Switch to clock_timestamp() (actual write moment,
after the lock-wait); greatest(existing, excluded) stays monotonic since
clock_timestamp() >= now() within a tx. getDbNowMs (the iat source) is
unchanged and stays in the same DB wall-clock domain.

R5-3: the /join-callback minted a join_code with a null githubAccountId when
GET /user/installations succeeded but GET /user failed, yielding a
github_account_id=NULL membership that member_removed's proof-id sever can
never match (leaks listing visibility until the 24h TTL; deploy-inert). Add a
fail-closed null-callerAccountId guard mirroring the connect-authorize
refusal, so every membership is attributable/severable.

R5-1: document (not code) that member_removed's no-org-install early-return
records no tombstone when a removal races ahead of installation.created — a
gap bounded by the accepted live-proof residual (a fresh org owner-adopt
requires a live authorizeInstallViaUserToken proof that fails once GitHub
propagates the removal), so no account-level tombstone is added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): redact repositories cache for non-owner members on GET /github/installations

The org-shared member-listing gate (owner OR active member) let a JOINed
member receive the install's cached `repositories` array — repo names/ids they
proved only installation-level access to, not per-repo access. Material for
`selected` installs (the `all` cache is stored empty). Pre-branch only the
install owner (an org admin) could list, so this exposure is introduced by the
membership arm; the per-repo proof lives at connect-authorize
(verifyUserRepoAccess, the member's own token), which does not source repos
from this listing.

Redact `repositories` -> [] for rows the caller does not own
(`inst.user_id === user.id`, the same owner check the connect route uses),
ONLY in this route's response mapping. Owners keep the full list; the DB
helper, schema, type, and owner connect-cache path (`selected` id resolution)
are untouched. Every other field is preserved (tight redaction).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(api): correct member_removed idempotency claim (R7-2 fail-safe residual)

Adversarial round 7 showed the handler is NOT unconditionally idempotent: a
stale redelivery of an old member_removed landing after X was re-added and
legitimately re-joined+reconnected re-runs the sever against current state
(cutting the fresh connection) and over-advances the clock_timestamp()
tombstone past the fresh artifact's iat.

This is a bounded, FAIL-SAFE (over-revoke, never under-revoke), self-recovering
(X re-joins) best-effort residual, matching the container/OIDC boundaries.
Correct the over-strong "Idempotent" comment to state this honestly. The
behavioral "fix" (a live GET /orgs/:org/memberships/:user recheck gating the
sever) is deliberately NOT taken: it would trade this safe over-revocation for
an UNSAFE under-revocation whenever GitHub's membership API lags a genuine
removal (2xx returned -> no redelivery -> permanent). Always-sever is the
security-correct default. Comment-only; no behavioral change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): member_removed must not sever oauth-backed connections via browser-proof memberships (R8-1)

member_removed(X) resolved affected users as the UNION of the oauth user for X
AND every user holding a membership whose github_account_id = X, then severed
those users' connections across ALL org installs. But a membership's
github_account_id is the BROWSER PROOF captured at JOIN (deferred-bind),
decoupled from the user's immutable oauth id. So a user V (oauth Z) with an
X-keyed listing membership on one install and a legitimate Z-backed connection
on another had that valid connection DELETED (and its in-flight builds killed)
when X left the org — an over-revocation (fail-safe, but real availability
loss). Reachable without any re-link: V simply controls two org-member GitHub
identities, or the deferred-bind decoupling of login vs browser proof.
getGitHubInstallationsByAccount has no status filter, so a historical
uninstalled install co-exists with a reinstall in the scope.

Invariant (verified: single insert site createGitHubConnection <- the connect
gate, which requires project.user_id === caller; owner branch oauth-proven;
member branch Threat-L1 grant.githubAccountId === caller's oauth id): EVERY
connection is backed by the connecting user's immutable oauth id, so arm (a)
getUserByOAuthAccount(X) provably covers every X-backed connection and every
X-owned install. Browser-proof memberships never back a connection.

Make arm (a) the SOLE driver of connection-severing + owner-clearing (+ its own
membership delete). Replace arm (b)'s user-id resolution with
deleteGitHubInstallationMembersByGithubAccount, which only voids the X-keyed
LISTING memberships. Removes now-unused getInstallationMemberUserIdsByGithubAccount.
Orthogonal to R4-1 (arm a stays post-lock) and the tombstone (unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): re-check install status + owner repo-coverage under the connect lock (R9-1)

The connect gate's go-live tx locks the install row but re-read only user_id, so
a concurrent teardown that won the lock first — installation.deleted/suspend
(marks status inactive; the row is NOT dropped, so the missing-row guard misses
it) or installation_repositories-removed (de-scopes a repo) — was not observed,
and the waiting connect inserted an orphan connection into an inactive/de-scoped
install after the teardown pass. Bounded (deploy resolvers require status
'active', so orphans are inert) but a real post-lock consistency gap; the
serialization already exists (both lock the install row install-first).

Extend the locked SELECT to read status/repository_selection/repositories and
re-check under the lock: status != 'active' -> 409 (BOTH owner and member paths);
owner selected-cache miss -> 400 (OWNER path only). The member path stays
cache-INDEPENDENT by design — it trusts the live-proved grant.repositoryId
(confused-deputy close), and the cache can lag a repo addition, so a member
cache-check would false-reject a valid grant; a stale member grant for a removed
repo just yields an inert connection. Reuses the existing pre-tx error strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): fail closed at org-share redemption paths when App user OAuth is unconfigured (R10-1)

The begin routes (join-url, connect-authorize-url, link-url) 503 via
isAppUserOAuthConfigured when GITHUB_APP_CLIENT_ID/SECRET are absent, but the
pure signed-artifact REDEMPTION paths did not re-check it: /join/complete
redeemed a join_code into a membership, and the connect gate's non-owner branch
redeemed a connect_grant into a connection — both with no live GitHub call. So
removing the config was not a true kill switch: artifacts minted before it
disappeared still created rows until their ~15-min TTL.

Add the same isAppUserOAuthConfigured 503 guard as the first statement of
/join/complete and as the first statement inside the connect gate's
`if (!isOwner)` block (before the membership check). The OWNER connect path
(no user OAuth) and the callbacks (implicitly fail-closed via their live token
exchange) are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): org-share is Organization-only — gate JOIN/membership/connect on account_type (R12-1)

The org-shared install feature is Organization-scoped by design: its only
event-driven revocation, organization/member_removed, never fires for personal
(account_type='User') accounts. But the JOIN -> membership -> non-owner-connect
path enforced org-only nowhere. A user who merely collaborates on a repo under
someone else's personal-account App install could get a join_code, redeem a
membership row, pass the non-owner connect gate (verifyUserRepoAccess proves
live repo access at connect time), and create a deploy connection with NO
revocation path — it survives loss of GitHub access and keeps auto-deploying.

Add an account_type === 'Organization' guard at all four gates so a personal
install can never be shared:
- join eligibility (github.ts:756): mint no join_code for a personal install
- /join/complete (github.ts:874): per-install fail-soft skip
- install-callback membership backstop arm (github.ts:1279): a personal
  different-user hit falls through to the existing 403 conflict (owner-adopt
  path untouched)
- non-owner connect gate (github.ts:1465): pre-lock opaque 404, defense-in-depth
  behind the mint guards (account_type is immutable, so no locked re-read)

Owner self-connect is unaffected (owners never use JOIN). Adds 5 guard-sensitive
regressions. Also folds in the R10-1 test-env fix Task 27 omitted: wire
GITHUB_APP_USER_OAUTH_ENV into the /join/complete fetch sites in
github-join-routes.test.ts (13 tests the 503 kill-switch gate had left red).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): close OAuth-off install-existence oracle in the connect gate (R13-1)

The non-owner branch of POST /projects/:id/github returned the OAuth kill-switch
503 (github_oauth_not_configured) BEFORE the membership check. Since the
missing-install lookup already returns an opaque 404 for a nonexistent
installationId, the block is entered only when the install exists — so with App
user OAuth unconfigured, a non-owner probing installationIds could distinguish an
existing non-owned install (503) from a nonexistent one (404). That existence
oracle violates the route's stated "never reveal the install exists to a
non-member" invariant and is worst in the exact kill-switch state. (Regression
from R10-1, whose comment wrongly claimed the 503 "leaks nothing about existence".)

Reorder the if (!isOwner) block so the two opaque-404 gates run first and the 503
runs last: membership -> 404, account_type != Organization -> 404, then
isAppUserOAuthConfigured -> 503. A non-member now gets the same opaque 404 as a
nonexistent install in both OAuth states; only a caller already proven to be an
active member of an Organization install reaches the 503 (kill switch preserved —
a member already knows the install exists, so it leaks nothing new). No status
code, body, or condition changed — pure reorder + corrected comment. Adds an
oracle-closed regression (bare-env non-member probe of an existing vs nonexistent
install both return 404).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): cleared-owner org installs are member-only — block owner re-adoption (R15-1)

organization/member_removed clears an org install's owner to NULL and writes a
revocation tombstone atomically. The install callback's different-user backstop
guard required user_id !== null, so a cleared (null-owner) row fell through to the
owner-adopt path; the adopt tombstone check keys on the COMPLETER's own id, so a
DIFFERENT org member B (never removed, no tombstone) was not blocked and
upsertGitHubInstallation adopted null -> B. B became the installation owner and the
owner connect path skips the connect_grant / verifyUserRepoAccess per-repo proof,
letting B connect/deploy org repos B cannot personally access — a confused-deputy
that defeats the feature's core promise.

Treat a cleared-owner org install as shared/member-only. A null-owner ORGANIZATION
install is either FRESH (installation.created precreates user_id=null, first
installer adopts) or CLEARED (member_removed wrote a tombstone with the owner-clear);
distinguish them by the tombstone: a null-owner org install WITH any revocation
tombstone is routed into the same membership-or-conflict branch as a
different-non-null-owner org install (grant a TTL membership on the proven
user-token path, keep owner NULL), while one with NO tombstone still owner-adopts.
New read helper hasInstallationRevocation(installationId). The only new ownership
outcome is Organization + null owner + has-tombstone -> member-only; same-owner
refresh, fresh org/personal owner-adopt, and different-user personal conflict are
unchanged. Fails closed (a fresh install racing a member-removal is left ownerless
but works via the member path; never the reverse).

A removed-then-readded ORIGINAL owner now reconnects via the member path (per-repo
proof) instead of reclaiming ownership — the RFC default (clear owner, fall back to
members); the corresponding test is updated to assert membership + null owner. Adds
regressions for the closed confused-deputy and the preserved fresh-install adopt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump framework submodule to org-shared GitHub CLI (#251)

Points the framework submodule at f50b223 (framework main), the squash-merge
of #251, which lands the CLI half of RFC 0077: `void github
join`, the non-owner connect-authorize round-trip, and the non-TTY fail-closed
guard. Pairs with this PR's API half.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(0074): add eventual-consistency (missed webhooks) reconciliation design

Documents the delivery-replay backstop (layer 1), connection-lease and
periodic app-auth reconciler (layers 2-3), plus the fail-safe vs fail-open
classification of each missed webhook event. Implementation of layer 1
(delivery replay) follows in this PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): github_webhook_redeliveries ledger + db helpers (webhook replay task 1)

Add the github_webhook_redeliveries table (schema.ts) plus migration
0032_remarkable_skullbuster.sql (offline drizzle-kit generate) and the
getRedeliveredGuids / recordWebhookRedelivery db helpers. The ledger
dedups GitHub webhook redeliveries on the stable delivery guid via a
unique index + onConflictDoNothing, so the (later) hourly replay job
retries each failed delivery at most once. Additive, isolated table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): app-JWT webhook-deliveries client (list failures + redeliver) (webhook replay task 2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): webhook-deliveries client never throws on a bad app key (task 2 review)

Both listFailedWebhookDeliveries and redeliverWebhookDelivery minted the app
JWT (createAppJwt -> importPKCS8) outside any try/catch. A malformed
GITHUB_APP_PRIVATE_KEY makes importPKCS8 throw, so a bad key propagated an
exception and broke the module's stated never-throws contract (presence of the
secret != validity of the key). Move the mint inside the guarded region: on a
throw the list returns the collected-so-far array ([] before any page) and the
redeliver returns false, logged via the shared area:'github' warn shape
(reason:'jwt-error'). Adds two covering unit tests (malformed PEM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): scheduled webhook delivery-replay step (webhook replay task 3)

Add replayFailedWebhookDeliveries(env, db, opts): lists GitHub's
status=failure delivery log, redelivers each failed event whose guid we
haven't already recorded (deduped against the ledger and within the
batch), and records each success. App-secret gated, per-run attempt cap
(50), page cap (5); best-effort and never throws.

Wire it into the hourly scheduled() branch as a catch-isolated step.
Add github_webhook_redeliveries to the clearTables integration helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(api): fmt drizzle 0032 snapshot + journal (webhook replay task 1 fixup)

drizzle-kit emitted the 0032 snapshot/journal in raw multi-line format; the
repo commits oxfmt-formatted drizzle artifacts (0031 and earlier pass
`vp fmt --check`). Reflow the two generated files so CI's `vp fmt --check`
stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(api): cover webhook-deliveries list retry + per_page clamp (final review)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(api): use Array<T> not T[] in webhook-deliveries + tests (oxlint array-type)

CI lint-and-check (oxlint) forbids the `T[]` array shorthand — the 7 new
`FailedWebhookDelivery[]` / `DeliveryItem[]` / `string[]` annotations tripped
the `typescript(array-type)` rule. Switch them to `Array<T>`. Lint exit 0,
fmt/typecheck clean, affected tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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