Problem
issueOrbEnrollment (src/orb/broker.ts:57-75) is a bare INSERT. It never revokes, updates, or even
counts sibling rows for the same installation_id:
INSERT INTO orb_enrollments (…) VALUES (…, 'enrolled', …)
handleOrbEnrollment (src/orb/oauth.ts:112) calls it on every successful callback, so an admin who
re-runs the install flow accumulates N simultaneously-valid secrets.
src/orb/relay.ts:533-538 already documents the consequence — "issueOrbEnrollment INSERTs a new row per
enrollment without revoking prior enrolled rows" — but #1783 only fixed the push-target election, not
the lifecycle.
The only revocation path, revokeOrbEnrollment, is reachable solely through
POST /v1/internal/orb/enrollments/:enrollId/revoke (src/api/routes.ts:4577), which sits behind
INTERNAL_JOB_TOKEN. A self-hosting maintainer has no way to reach it.
Trigger
A maintainer's .env leaks (or the browser page that displayed the secret does). They re-run the GitHub
install flow expecting rotation. They get a second secret; the leaked one keeps working.
Impact
A leaked enrollment secret is unrevocable GitHub write access (merge/close/comment) to the
maintainer's repos, until they uninstall the App entirely. The advertised zero-touch self-service
enrollment has no matching self-service revocation.
Requirements
- In
issueOrbEnrollment, mark prior state='enrolled' rows for the same installation_id as revoked —
or add an explicit ?rotate=1 confirmation step in the OAuth landing page so the operator chooses.
- Expose a maintainer-facing revoke behind the same admin-of-installation check
verifyInstallationAdmin
already implements correctly.
- Surface the count of live enrollments somewhere the operator can see it.
- Add
Cache-Control: no-store to secretPage (src/orb/oauth.ts:150), which currently renders the
enrollment secret into a cacheable HTML GET response.
Test Coverage Requirements
99%+ patch coverage, branch-counted; both arms of revoke-on-reissue, and a test that a superseded secret
no longer mints a token.
Links & Resources
maintainer-only — credential lifecycle.
Problem
issueOrbEnrollment(src/orb/broker.ts:57-75) is a bare INSERT. It never revokes, updates, or evencounts sibling rows for the same
installation_id:handleOrbEnrollment(src/orb/oauth.ts:112) calls it on every successful callback, so an admin whore-runs the install flow accumulates N simultaneously-valid secrets.
src/orb/relay.ts:533-538already documents the consequence — "issueOrbEnrollment INSERTs a new row perenrollment without revoking prior enrolled rows" — but #1783 only fixed the push-target election, not
the lifecycle.
The only revocation path,
revokeOrbEnrollment, is reachable solely throughPOST /v1/internal/orb/enrollments/:enrollId/revoke(src/api/routes.ts:4577), which sits behindINTERNAL_JOB_TOKEN. A self-hosting maintainer has no way to reach it.Trigger
A maintainer's
.envleaks (or the browser page that displayed the secret does). They re-run the GitHubinstall flow expecting rotation. They get a second secret; the leaked one keeps working.
Impact
A leaked enrollment secret is unrevocable GitHub write access (merge/close/comment) to the
maintainer's repos, until they uninstall the App entirely. The advertised zero-touch self-service
enrollment has no matching self-service revocation.
Requirements
issueOrbEnrollment, mark priorstate='enrolled'rows for the sameinstallation_idas revoked —or add an explicit
?rotate=1confirmation step in the OAuth landing page so the operator chooses.verifyInstallationAdminalready implements correctly.
Cache-Control: no-storetosecretPage(src/orb/oauth.ts:150), which currently renders theenrollment secret into a cacheable HTML GET response.
Test Coverage Requirements
99%+ patch coverage, branch-counted; both arms of revoke-on-reissue, and a test that a superseded secret
no longer mints a token.
Links & Resources
src/orb/broker.ts~57-75;src/orb/oauth.ts~112, ~150;src/orb/relay.ts~533-538;src/api/routes.ts~4577maintainer-only — credential lifecycle.