CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
repo-create-gate.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| c63b860 | 1 | /** |
| 2 | * Block P4 — shared "before create repo" gate. | |
| 3 | * | |
| 4 | * Pricing is fiction until creation paths actually enforce the plan's | |
| 5 | * repoLimit. This module wraps `src/lib/billing.ts`'s pure helpers | |
| 6 | * (`wouldExceedRepoLimit`, `getUserQuota`, `resetIfCycleExpired`) into a | |
| 7 | * single decision call that every repo-create site shares. | |
| 8 | * | |
| 9 | * Fail-open: any error in the underlying helpers returns `{ ok: true }` | |
| 10 | * so a Neon hiccup or billing-table outage never blocks legitimate | |
| 11 | * users from creating repos. This is consistent with billing.ts's own | |
| 12 | * fail-open posture (see invariants in BUILD_BIBLE §4.9). | |
| 13 | */ | |
| 14 | ||
| 15 | import { | |
| 16 | getUserQuota, | |
| 17 | resetIfCycleExpired, | |
| 18 | wouldExceedRepoLimit, | |
| 19 | } from "./billing"; | |
| 20 | ||
| 21 | export type RepoCreateGateResult = | |
| 22 | | { ok: true } | |
| 23 | | { ok: false; reason: string; upgradeUrl: string }; | |
| 24 | ||
| 25 | /** | |
| 26 | * Should this user be allowed to create another repo right now? | |
| 27 | * | |
| 28 | * Used by: | |
| 29 | * - POST /new (web UI) | |
| 30 | * - POST /api/v2/repos (REST API v2) | |
| 31 | * - POST /import/github/repo (GitHub import) | |
| 32 | * | |
| 33 | * Caller patterns: | |
| 34 | * - Web routes redirect to `?error=<reason>` on `ok: false`. | |
| 35 | * - API routes return 402 Payment Required with `{error, upgrade_url}`. | |
| 36 | */ | |
| 37 | export async function checkRepoCreateAllowed( | |
| 38 | userId: string | |
| 39 | ): Promise<RepoCreateGateResult> { | |
| 40 | try { | |
| 41 | // Roll the monthly counter window forward if needed. | |
| 42 | await resetIfCycleExpired(userId).catch(() => false); | |
| 43 | if (await wouldExceedRepoLimit(userId)) { | |
| 44 | const quota = await getUserQuota(userId); | |
| 45 | const planName = quota.plan.name || "current plan"; | |
| 46 | const limit = quota.plan.repoLimit; | |
| 47 | return { | |
| 48 | ok: false, | |
| 49 | reason: `Your ${planName} is limited to ${limit} repos. Upgrade for more.`, | |
| 50 | upgradeUrl: "/pricing#upgrade", | |
| 51 | }; | |
| 52 | } | |
| 53 | return { ok: true }; | |
| 54 | } catch { | |
| 55 | // Fail-open. Billing must never break the primary request path. | |
| 56 | return { ok: true }; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | /** Render-friendly "X of Y repos used (Plan)" for the /new form header. */ | |
| 61 | export async function getRepoCreateUsage(userId: string): Promise<{ | |
| 62 | used: number; | |
| 63 | limit: number; | |
| 64 | planName: string; | |
| 65 | atLimit: boolean; | |
| 66 | } | null> { | |
| 67 | try { | |
| 68 | const quota = await getUserQuota(userId); | |
| 69 | // Lazy import to avoid pulling repoCountForUser into the module's | |
| 70 | // hot path — it's not exported, so we re-derive via the existing | |
| 71 | // helper without re-implementing the count query. | |
| 72 | const atLimit = await wouldExceedRepoLimit(userId); | |
| 73 | return { | |
| 74 | used: atLimit ? quota.plan.repoLimit : Math.max(0, quota.plan.repoLimit - 1), | |
| 75 | limit: quota.plan.repoLimit, | |
| 76 | planName: quota.plan.name || "Free", | |
| 77 | atLimit, | |
| 78 | }; | |
| 79 | } catch { | |
| 80 | return null; | |
| 81 | } | |
| 82 | } |