Repository navigation
feat(cli): void init --github — scaffold GitHub Actions OIDC deploy workflow - #217
Conversation
…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>
❌ Deploy Preview for staging-void-cloud failed.
|
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
…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>
…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>
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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".
…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>

Summary
Adds
void init --github, the CLI side of Deploy on GitHub. It scaffolds.github/workflows/void-deploy.yml, which:void) —permissions: id-token: write, no long-lived secret stored in the repoPOST $VOID_API_URL/auth/github-oidcfor a short-lived, project-scoped Void deploy tokenvoid deployon the GitHub-hosted runnerOn 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 companionvoid-platformPR.Changes
--githubflag onvoid init(workflow-only scaffold; safe to re-run, skips if the workflow already exists)void init --githubCLI help + examples entryVOID_API_URLrepo var (defaults tohttps://api.void.cloud) and per-(repo, ref)concurrencydocs/guide/deployment.md+docs/reference/cli.mdinit.test.tscoverage for the new flag + scaffoldTest
init.test.ts: 129 passed🤖 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_TOKENsecret anddeploy.ymltovoid init --githubwriting.github/workflows/void-deploy.yml:id-token: write, repo/ref concurrency, OIDC exchange atPOST $VOID_API_URL/auth/github-oidc, thenvoid deploy --project. The workflow bakes in the linked slug from.void/project.jsonwhen present, otherwisevars.VOID_PROJECT; interactive init patches the placeholder after project linking. Docs and CLI help describe OIDC and staging viaVOID_API_URL.void deployaddsbuildEnv()soCF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRETare removed from child env forvite build, git metadata, and drizzle-kit (parentprocess.envunchanged for interactive re-auth). Deploy done events may includepublished: false; the CLI still treats the deploy as success but shows degraded spinner text, logsdeploy_degraded, andwarnUnpublishedRoutingafter 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.