From 4444ee89014a82f1b7d9b418991cae986e510957 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 28 May 2026 14:58:52 +0200 Subject: [PATCH 1/8] docs(coding-plans): define managed subscription pilot contract --- .specs/coding-plans.md | 145 ++++++ .specs/subscription-center.md | 219 ++++---- PLAN.md | 933 ++++++++++++++++++++++++++++++++++ 3 files changed, 1210 insertions(+), 87 deletions(-) create mode 100644 .specs/coding-plans.md create mode 100644 PLAN.md diff --git a/.specs/coding-plans.md b/.specs/coding-plans.md new file mode 100644 index 0000000000..da3692649e --- /dev/null +++ b/.specs/coding-plans.md @@ -0,0 +1,145 @@ +# Coding Plans + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC 2119] [RFC 8174] when, and only when, they appear in capitalized form. + +--- + +## Definitions + +**Coding Plan** - A recurring subscription product that grants a user access through Kilo to an upstream provider plan by installing its issued credential in the user's existing personal BYOK provider slot. A user's later BYOK changes do not alter Coding Plan billing. + +**Plan Catalog** - The set of Coding Plan offerings enabled by Kilo. The catalog is controlled by trusted application configuration and may contain one or more offerings. + +**Plan ID** - A stable identifier for a purchasable Coding Plan offering. A Plan ID is distinct from the upstream provider or routing identifier used to execute API traffic. + +**Managed Plan Credential** - An upstream API key acquired or provisioned by Kilo for a Coding Plan. Kilo manages its assignment and revocation. It is not exposed to the subscriber after it is installed in BYOK. + +**Installed BYOK Configuration** - A normal personal BYOK entry that Kilo initially populates with a Managed Plan Credential. While unchanged, it identifies Token Plan Plus as its origin and Kilo may delete it at Effective Cancellation. A subscriber may test, enable, disable, update, or delete it using normal BYOK operations. Replacing its credential transfers cleanup ownership to the subscriber. + +**Availability Notification Intent** - A user's plan-scoped request to be notified when a sold-out Coding Plan has capacity again. It is not a reservation, purchase, subscription, or entitlement. + +**Manual Revocation Work Item** - Durable inventory remediation state requiring authorized support staff to revoke an issued MiniMax credential through the provider admin process and record its outcome in Kilo. The initial pilot represents this work on the inventory row and does not require a separate remediation audit-event history. MiniMax does not provide an automated revocation integration for the initial release. + +**Kilo Credits** - The unit of account used for Coding Plan billing. The pricing layer manages conversion to internal microdollar accounting; user-facing surfaces display `Credits` as the payment source and charged amounts in USD. + +**Upstream Provider** — An external API vendor whose plan access is offered through Kilo. The initial planned offering uses MiniMax. + +**Obfuscated Identity** — An irreversible, per-provider cryptographic hash of a user's internal identifier, used when Kilo must identify a subscriber to an upstream provider without sending personally identifiable information. + +**Effective Cancellation** — The time when a Coding Plan ceases to provide access. For a user-requested cancellation, this is the end of the already-paid billing period. For account deletion or an immediate administrative termination, this is immediate. + +--- + +## 1. Plan catalog + +1.1. The system **MUST** present the configured Plan Catalog within app.kilo.ai. The catalog **MAY** contain a single offering. + +1.2. Each catalog entry **MUST** have a stable Plan ID and **MUST** display its provider name, plan name, recurring USD price, billing period, and payment source. Coding Plans paid from a user's credit balance **MUST** identify `Credits` as their payment source. + +1.3. Kilo's backend **MUST** be the source of truth for the set of available Coding Plans and their pricing. A change to enabled offerings or price **MUST** be deployed through controlled application configuration or code review. + +1.4. A Plan ID **MUST NOT** be treated as the upstream provider identity. Multiple future plans from one upstream provider **MAY** coexist without extending or replacing one another unless their product rules explicitly state otherwise. + +1.5. The initial implementation is intended to configure one offering: MiniMax Token Plan Plus. This initial offering does not impose a requirement that future catalogs contain MiniMax or any minimum number of offerings. + +1.6. When no assignable Managed Plan Credential exists for an offering, customer-facing catalog responses **MUST** identify the offering as sold out without exposing credential counts or credential metadata. + +## 2. Subscription and billing + +2.1. Users **MUST** be able to purchase a Coding Plan using Kilo Credits through Kilo. The system **MUST NOT** redirect a user to an upstream provider to subscribe. + +2.2. The system **MUST** allow at most one non-terminal subscription for a given user and Plan ID. A terminal subscription **MUST NOT** prevent a later new subscription to the same Plan ID. + +2.3. Each purchase request **MUST** include an idempotency key scoped to the user and Plan ID. Retrying a successfully processed request with the same idempotency key **MUST** return the original outcome and **MUST NOT** create an additional billing period, charge, or credential assignment. + +2.4. The initial release **MUST NOT** sell an additional prepaid period for a non-terminal subscription. A successful idempotent retry **MUST** return the existing purchase result; all other purchase attempts while a subscription is `active` or `past_due` **MUST** be rejected. Later-period billing occurs only through recurring renewal in the initial release. + +2.5. The system **MUST** atomically perform initial activation: verify that the user's personal provider slot is unoccupied, debit sufficient Kilo Credits using a guarded balance operation, claim an available Managed Plan Credential, create the Installed BYOK Configuration, record the charged term, and create the active subscription. If any step fails, none of those effects **MUST** commit. + +2.6. A failed initial activation **MUST NOT** commit a debit that later needs a compensating refund. The user **MUST** receive an error that indicates whether the failure was insufficient credits or unavailable plan capacity without exposing credential material. + +2.7. Each charged term **MUST** snapshot the plan price and billing period applied to it. A later catalog price change **MUST NOT** retroactively alter a paid term. + +## 3. Customer relationship and managed access + +3.1. Kilo **MUST** manage the subscriber's account relationship, subscription status, and Kilo Credit billing. Kilo **MUST NOT** send a subscriber's email address, name, password, or other personally identifiable account data to an upstream provider. + +3.2. When an upstream operation needs subscriber attribution, Kilo **MUST** use only an Obfuscated Identity unless another non-PII identifier is required by an approved provider contract. + +3.3. A Managed Plan Credential remains controlled by Kilo for inventory and revocation. A subscriber **MUST NOT** receive its raw value through application UI or API responses in the initial release. + +3.4. On activation, Kilo **MUST** configure an Installed BYOK Configuration in the ordinary personal provider slot so eligible traffic can use the plan through the Kilo Gateway. Activation **MUST** fail without charge or assignment if that provider slot is occupied, including by a disabled key. + +3.5. While an Installed BYOK Configuration still contains Kilo's issued credential, user-facing BYOK surfaces **MUST** identify its Coding Plan origin. Ordinary BYOK test, enable/disable, update, and delete operations **MUST** remain available. Before updating, disabling, or deleting that configuration, Kilo **MUST** warn that the operation changes routing but does not cancel subscription billing and **MUST** direct cancellation to the Subscription Center. Updating the credential **MUST** mark the entry as user-managed and detach it from later Coding Plan cleanup; deleting it **MUST NOT** cancel or pause the subscription. Testing or re-enabling the key does not require this warning. + +## 4. Credential provisioning and inventory + +4.1. Kilo **MUST** acquire or provision Managed Plan Credentials before accepting a purchase that depends on them. For an offering initially provisioned by operator upload, only authorized administrative tooling **MAY** insert credentials into inventory. + +4.2. Available and assigned credentials **MUST** be encrypted at rest. Raw credentials **MUST NOT** appear in logs, analytics, error messages, customer responses, ordinary BYOK responses, or administrative inventory list responses. For the initial manual-revocation pilot, an authorized admin **MAY** reveal one raw issued credential through an explicit sensitive-data action for an inventory item in `revocation_pending` or `revocation_failed` state so support can revoke it in MiniMax. + +4.3. Inventory **MUST** distinguish at least these credential lifecycle states: available, assigned, revocation pending, revoked, and revocation failed. + +4.4. An available credential **MUST** be assigned at most once. Once assigned, it **MUST NOT** return to available inventory, including after cancellation, failed revocation, user deletion, or a later re-subscription. + +4.5. A new subscription **MUST** be confirmed active only after an available credential is claimed and its Installed BYOK Configuration has been created within Kilo. + +4.6. When no available credential exists for the requested plan, activation **MUST** fail without debiting credits or creating a subscription. + +4.7. Kilo **MUST** retain non-secret assignment and revocation disposition evidence on inventory records for the required operational and compliance retention period. Encrypted credential material **MAY** remain available through the admin-only manual-remediation workflow while revocation requires it. Once support confirms revocation and no further remediation requires the credential value, Kilo **MUST** remove retained encrypted credential material from the terminal inventory record. After the applicable retention period, terminal credential records **MAY** be deleted without deleting billing history. + +4.8. Administrative upload tooling **MUST** prevent accidental duplicate credential assignment without exposing raw credential values in list responses, for example through a secure, non-reversible fingerprint comparison. + +4.9. Before a MiniMax credential becomes `available` inventory, administrative upload tooling **MUST** validate that it can use the approved ordinary MiniMax routing and model behavior for Token Plan Plus. An invalid or incompatible credential **MUST NOT** become assignable inventory. + +## 5. Subscription lifecycle + +5.1. A Coding Plan with successful activation enters `active` state and remains billable until its paid period ends or it is terminated immediately under this section. A subscriber's BYOK updates, disablement, or deletion **MUST NOT** change that billing lifecycle. + +5.2. When a user requests cancellation, the subscription **MUST** stop renewing and **MUST** remain paid through the end of its current period. On the first billing lifecycle sweep processing the subscription at or after Effective Cancellation, Kilo **MUST** delete its Installed BYOK Configuration only if that configuration is still linked and Kilo-installed, and **MUST** create a Manual Revocation Work Item for the originally issued credential. Kilo **MUST NOT** delete a replacement or later user-created provider key. + +5.3. An uninterrupted successful renewal **MUST** extend paid access using the existing assigned credential. The system **MUST** debit the snapshotted renewal price atomically with recording the new charged term. + +5.4. If a subscription reaches renewal without sufficient Kilo Credits and the user has not enabled applicable auto-top-up, the next billing lifecycle sweep **MUST** terminate it, delete only a still-linked Installed BYOK Configuration, and create a Manual Revocation Work Item for the issued credential. + +5.5. If a subscription reaches renewal without sufficient Kilo Credits and the user has enabled applicable auto-top-up, the system **MUST** trigger no more than one auto-top-up attempt for that due term, move the subscription to `past_due`, and allow payment recovery for a grace period calculated as no more than 24 hours from the due time. + +5.6. During the `past_due` recovery period, arrival of sufficient credits before the stored grace deadline **MUST** allow one atomic renewal debit and restore `active` status. If renewal cannot be funded by that deadline, the next billing lifecycle sweep **MUST** terminate the subscription, delete only a still-linked Installed BYOK Configuration, and create a Manual Revocation Work Item for the issued credential. + +5.7. A user-requested cancellation **MUST NOT** trigger auto-top-up. Updating, disabling, or deleting an Installed BYOK Configuration **MUST NOT** trigger cancellation or affect renewal billing. + +5.8. When a user account is deleted, Kilo **MUST** immediately terminate any Coding Plan subscription, delete the user's BYOK configurations and Availability Notification Intents under the general deletion policy, create a Manual Revocation Work Item for each issued credential, and anonymize subscriber linkage in retained credential disposition records. Account deletion **MUST NOT** wait until the end of a prepaid period. Subscription and charged-term history **MAY** remain associated with the platform's anonymized user record when required for financial or compliance retention. + +5.9. Manual upstream revocation **MUST** be completed by authorized support through the MiniMax admin process and its outcome **MUST** be recorded on the inventory item in Kilo. Pending and failed work **MUST** remain visible in the admin console for remediation. Kilo **MUST** keep the Coding Plan terminated while revocation is pending or failed. An issued credential awaiting or failing revocation **MUST NOT** be reassigned; a separate user-managed provider key **MUST NOT** be removed because of revocation work. + +5.10. The initial pilot **MAY** leave an unchanged Kilo-installed BYOK configuration routable between its paid-period or grace deadline and the next scheduled billing lifecycle sweep. Once that sweep processes termination, local Kilo-installed access **MUST** be deleted regardless of whether manual upstream revocation is complete. + +## 6. Traffic routing + +6.1. Initial Token Plan Plus setup **MUST** route through the Kilo Gateway using the existing ordinary personal MiniMax BYOK provider identity. The initial release **MUST NOT** expose saved raw credential values through Kilo UI or API responses. + +6.2. The system **MUST NOT** add a Token Plan Plus-specific provider or model-routing namespace. The Kilo-installed MiniMax key and any later subscriber replacement **MUST** use ordinary MiniMax BYOK routing and model availability. + +6.3. Purchase **MUST** reject an occupied personal MiniMax BYOK slot before a charge or issued credential assignment commits. Once subscribed, a user's ordinary MiniMax BYOK actions affect routing configuration only; Coding Plan billing and revocation of Kilo's originally issued credential remain independent. + +## 7. User-facing behavior + +7.1. Users **MUST** be able to view catalog offerings, purchase a Coding Plan, view their subscription status and paid-period dates, and request cancellation from Kilo surfaces. + +7.2. Coding Plan surfaces **MUST** display recurring prices and charged-term amounts in USD regardless of payment source. Kilo Credits are valued one-to-one with USD for display. Surfaces **MUST** identify `Credits` as the payment source for credit-funded subscriptions and **MUST NOT** expose internal microdollars. + +7.3. While an Installed BYOK Configuration is unchanged, the BYOK surface **MUST** identify it as configured by Token Plan Plus. Before updating, disabling, or deleting it, the surface **MUST** warn that routing changes do not cancel subscription billing and direct the user to Subscription Center to cancel. Saved raw-key view or copy controls **MUST NOT** be added for customer BYOK surfaces. + +7.4. Purchase messaging **MUST** state that Kilo configures MiniMax in BYOK and **MUST** tell users with an existing MiniMax key to delete it before subscribing. Cancellation messaging **MUST** state when billing ends, that Kilo deletes only its unchanged installed configuration, and that Kilo revokes its issued credential when plan access ends. + +7.5. A `past_due` subscription **MUST** communicate its grace deadline with date and local time, the consequence of unsuccessful payment recovery, and that a replacement or user-created MiniMax BYOK key is not deleted by Coding Plan termination. + +7.6. A sold-out offering **MUST** display its unavailable state and **MUST** offer an authenticated user a way to record an Availability Notification Intent. Recording the same intent again **MUST** be idempotent, **MUST NOT** reserve capacity or initiate billing, and **MUST** show the saved intent state. A successful activation **MUST** clear the activated user's intent for that Plan ID. + +## 8. Security and observability + +8.1. Logs and monitoring **MUST NOT** contain raw Managed Plan Credentials, credential-bearing authorization headers, provider-management secrets, or unfiltered provider/SDK key-test error content. + +8.2. General administrative credential inventory responses **MUST** return non-secret status and remediation metadata only. For a `revocation_pending` or `revocation_failed` item, the manual-revocation admin console **MAY** provide an explicit admin-only raw credential reveal action after a sensitive-data warning. Raw values **MUST NOT** appear in queue/list responses or customer surfaces. + +8.3. The initial pilot does not require a Coding Plans audit-log history for admin inventory upload, credential reveal, or manual revocation actions. Inventory lifecycle state, request/completion timestamps, attempt count, and sanitized failure information **MUST** record current disposition without retaining raw credentials after confirmed revocation. diff --git a/.specs/subscription-center.md b/.specs/subscription-center.md index a651281879..cca8eff460 100644 --- a/.specs/subscription-center.md +++ b/.specs/subscription-center.md @@ -14,6 +14,16 @@ choices belong in plan documents and code, not here. Draft -- created 2026-03-31. Updated 2026-05-12 -- KiloClaw price-version display behavior. +Updated 2026-05-26 -- Coding Plans managed-credential and catalog behavior. +Updated 2026-05-27 -- Coding Plans ordinary MiniMax BYOK setup and billing separation. +Updated 2026-05-27 -- Coding Plans manual MiniMax revocation handling. +Updated 2026-05-27 -- Token Plan Plus pilot operations and UI behavior. +Updated 2026-05-28 -- Personal product navigation and return context. +Updated 2026-05-28 -- USD price display independent of payment source. +Updated 2026-05-28 -- Coding Plans sold-out availability notification intent. +Updated 2026-05-28 -- Credit-funded payment source label. +Updated 2026-05-28 -- Coding Plans API key configuration summary. +Updated 2026-05-28 -- Coding Plans billing history USD amount display. ## Conventions @@ -28,9 +38,9 @@ capitals, as shown here. - **Subscription Center**: A unified page where users view and manage all of their subscriptions in one place. It exists at both a personal and organizational level. -- **Subscription Group**: A category of subscriptions displayed as a - visual section on the page. Current groups are Kilo Pass, KiloClaw, - Coding Plans, and Teams/Enterprise Seats. +- **Subscription Group**: A product category within the Subscription + Center. Current groups are Kilo Pass, KiloClaw, Coding Plans, and + Teams/Enterprise Seats. - **Subscription Card**: A summary element within a group that represents a single subscription instance and its current state. - **Available Product Card**: A card shown within a subscription group @@ -56,18 +66,21 @@ capitals, as shown here. current period; (c) it is trialing and the trial end date is approaching. The exact threshold for "approaching" is an implementation choice that MAY vary by subscription type. -- **Price**: All prices are denominated in USD and MUST be displayed - with a dollar sign and two decimal places (e.g. "$9.99/mo"). +- **Price**: Prices MUST be displayed in USD regardless of payment + source. Price labels MUST use a dollar sign and billing cadence (e.g. + "$20 /month"). Credit-funded products MUST display "Credits" as their + payment source separately from their USD price. ## Overview -The Subscription Center is a centralized page where users can see -every subscription they hold, grouped by product type: Kilo Pass, -KiloClaw, Coding Plans, and Teams/Enterprise Seats. Each group contains individual -subscription cards showing status, plan, pricing, and billing date at -a glance. Clicking a subscription card navigates to a detail page with -full management capabilities including plan changes, cancellation, -usage history, and invoice viewing. +The Subscription Center is a centralized page where users manage +subscriptions by product type: Kilo Pass, KiloClaw, Coding Plans, and +Teams/Enterprise Seats. The personal route provides access to each +personal product while preserving product context when users return from +its detail page. Each visible group contains subscription cards showing +status, plan, pricing, and billing date at a glance. Clicking a +subscription card navigates to a detail page with management capabilities +including plan changes, cancellation, usage history, and invoice viewing. The personal Subscription Center lives at `/subscriptions` and is accessible from the sidebar. Organization subscriptions live at @@ -79,8 +92,8 @@ only org-owned subscriptions. Subscription management UI may continue to appear in other parts of the app (e.g. Kilo Pass cards on the profile page, billing controls within the KiloClaw dashboard). The Subscription Center does not -replace those surfaces but serves as the canonical hub where all -subscriptions are consolidated. +replace those surfaces but is the canonical hub for moving between and +managing each subscription product. These routes form a stable URL contract. If any path changes in the future, the system MUST redirect from the old path to the new one. @@ -109,34 +122,36 @@ future, the system MUST redirect from the old path to the new one. ### Subscription Groups -6. The page MUST display subscriptions organized into groups by product - type. The initial groups are: +6. The page MUST organize subscriptions into groups by product type. The + initial groups are: - **Kilo Pass** (personal route only) - **KiloClaw** (personal route only) - **Coding Plans** (personal route only) - **Teams/Enterprise Seats** (org route only) -7. A group MUST always appear on its respective route regardless of - whether the user has a subscription of that type. +7. The personal route MUST provide access to each personal Subscription + Group regardless of whether the user has a subscription of that type. + The route MAY show only one group's content at a time. Returning from a + personal subscription detail page MUST restore the user's product + context. -8. When a group contains no subscriptions in a non-terminal state, the - system MUST display an Available Product Card with a call-to-action - to subscribe. +8. When a user views a group with no subscriptions in a non-terminal + state, the system MUST display an Available Product Card with a + call-to-action to subscribe. -9. When a group contains one or more subscriptions in a non-terminal - state, the system MUST display a Subscription Card for each - non-terminal subscription. +9. When a user views a group with one or more subscriptions in a + non-terminal state, the system MUST display a Subscription Card for + each non-terminal subscription. 10. Subscriptions in a terminal state (Kilo Pass: `canceled`, `incomplete_expired`; KiloClaw: `canceled`; Coding Plans: `canceled`; Teams/Enterprise: `ended`) MUST be hidden by default. - The page MUST provide a single page-level toggle that reveals or - hides all terminal subscriptions across all groups simultaneously. + Users MUST be able to reveal terminal subscriptions for each + applicable product. -11. When revealed, terminal subscription cards MUST use a visually - muted treatment — reduced opacity or desaturated color — and MUST - display a status label indicating the terminal state (e.g. - "Cancelled", "Ended"). +11. When revealed, terminal subscriptions MUST be clearly distinguished + from non-terminal subscriptions and MUST display their terminal + status (e.g. "Cancelled", "Ended"). ### Subscription Cards @@ -147,20 +162,17 @@ future, the system MUST redirect from the old path to the new one. - Price per billing period - Payment method summary (e.g. "Visa ending 4242" or "Credits") -13. Cards for subscriptions in a warning state MUST use a colored left - border or background tint that differs from the default card style, - using the application's existing warning/destructive color tokens. - The system MUST NOT use badges or notification indicators in the - navigation. +13. Cards for subscriptions in a warning state MUST communicate + prominently and accessibly that the subscription requires attention. 14. Each Subscription Card MUST be clickable and navigate to that subscription's detail page, regardless of subscription status. 15. Each subscription group MUST load independently. A failure or delay - in loading one group MUST NOT prevent other groups from rendering. + in one group MUST NOT prevent the user from viewing another group. -16. While a group's data is loading, the system MUST display a - placeholder skeleton for that group's cards. +16. While the requested group's data is loading, the system MUST provide + visible loading feedback for that group. ### Kilo Pass Subscriptions (Personal Route) @@ -231,57 +243,70 @@ future, the system MUST redirect from the old path to the new one. - Link to the Stripe customer portal for payment method management (if Stripe-funded) -KiloClaw Subscription Card price display MUST use the subscription -row's price version and renewal amount, when available, so live legacy -lineages show legacy pricing and current lineages show current pricing. -KiloClaw detail price and plan-switch displays MUST use the -subscription row's price version for both the current plan and any -scheduled or requested target plan. +KiloClaw summary and detail views MUST display the pricing applicable +to the subscription, including subscriptions enrolled under earlier +pricing. Scheduled or requested plan changes MUST display pricing that +will apply if the change completes. -The KiloClaw Available Product Card MUST use the fresh current-price -enrollment preview when the group has no non-terminal KiloClaw -subscription. Canceled KiloClaw history MUST NOT cause legacy pricing -or legacy entitlement to appear on subscribe surfaces. Stripe-funded -KiloClaw checkout pending invoice settlement MUST be displayed as -pending settlement rather than fully active. +When the user has no non-terminal KiloClaw subscription, the enrollment +view MUST display the currently available offer and price. Canceled +KiloClaw history MUST NOT cause an earlier price or entitlement to +appear as the available offer. Stripe-funded enrollment awaiting invoice +settlement MUST be presented as pending rather than active. ### Coding Plans Subscriptions (Personal Route) 27. A user MAY have multiple Coding Plans subscriptions — one per - upstream provider. The Coding Plans group MUST display one - Subscription Card for each active coding plan subscription. + configured Plan ID. The Coding Plans group MUST display one + Subscription Card for each non-terminal coding plan subscription, + including a `past_due` subscription in its warning state. 28. The Coding Plans detail page MUST be served at `/subscriptions/coding-plans/[subscriptionId]`. 29. Each Coding Plans detail page MUST support the following management actions: - - View subscription status (active, cancelled) - - Cancel subscription + - View subscription status (`active`, `past_due`, or `canceled`) + - Cancel an active subscription at the end of its paid period 30. Each Coding Plans detail page MUST display: - - Provider name and status - - Billing period and next renewal date - - Cost in Kilo Credits per billing period - - Payment source (Kilo Credits) - - Traffic routing information (Kilo Gateway or direct) - - The user's assigned API key with view and copy controls (see - Coding Plans spec, rule 4.2.1) - - Inline billing history showing credit transactions (see Billing - History rules) - -31. When a coding plan is cancelled or the user's credit balance is - insufficient to renew, the system MUST remove the API key from the - user's BYOK configuration within Kilo. The system MUST NOT revoke - the key with the upstream provider — the key belongs to the user - (see Coding Plans spec, rule 5.1). The cancel confirmation dialog - MUST communicate this to the user. - -32. When a group contains no active coding plans, the system MUST - display the available provider catalog inline as Available Product - Cards — one per upstream provider — showing the provider name, - recurring cost in Kilo Credits, and billing period. Each card MUST - have a subscribe action that initiates subscription creation. + - Provider name, plan name, and status + - Billing period and next renewal date, paid-through date, or grace + deadline as appropriate for its status; a grace deadline MUST include + local date and time + - Price in USD per billing period + - Payment source (Credits) + - API Key Configuration summary identifying configuration in BYOK and + linking to `/byok` when a managed key is installed + - Traffic routing information (Kilo Gateway through the ordinary + MiniMax BYOK provider setup) + - Inline billing history showing credit transactions with amounts in USD + (see Billing History rules) + + Before update, disable, or delete, `/byok` MUST warn that routing changes + do not cancel or pause Token Plan Plus billing and cancellation is managed + in Subscription Center; customer surfaces MUST NOT include saved raw-key + view or copy controls. + +31. Coding Plan cancellation, installed MiniMax configuration cleanup, + and issued-credential revocation MUST follow `.specs/coding-plans.md`. + Cancellation messaging MUST communicate the paid-through date, that + only Kilo's unchanged installed configuration is removed, and that + Kilo revokes its issued credential when plan access ends. + +32. When the user views Coding Plans with no non-terminal subscription, + the system MUST show each configured offering with provider name, + plan name, recurring USD price, billing period, and payment source. + An offering with assignable credential capacity MUST show a subscribe + action. For MiniMax Token Plan Plus, purchase messaging MUST explain + automatic MiniMax BYOK setup and purchase MUST be blocked when any + personal MiniMax BYOK key exists, including a disabled key. In that + state, the system MUST direct the user to delete the existing key in + `/byok` first. An offering without assignable credential capacity MUST + display a sold-out state and a `Notify me when available` action. The + action MUST persist one notification intent per user and Plan ID without + charging credits or reserving inventory, and the surface MUST indicate + once that intent has been saved. ### Teams/Enterprise Seats Subscriptions (Org Route) @@ -341,9 +366,9 @@ pending settlement rather than fully active. group's displayed state MUST NOT change. An abandoned or failed checkout MUST NOT alter what the page displays. -45. When the user has no subscriptions of any type, the personal - Subscription Center MUST display Available Product Cards for every - group — it MUST NOT show an empty page. +45. When the user has no subscriptions of any type, each personal + product MUST remain accessible and MUST present its subscribe + opportunity when viewed; the visible product view MUST NOT be empty. ### Billing History @@ -356,16 +381,16 @@ pending settlement rather than fully active. 48. For subscriptions funded entirely by credits (no Stripe billing), the billing history MUST display credit transaction history in - place of invoices — showing date, amount, and description for each - credit deduction. + place of invoices, showing date, USD-denominated amount, and description + for each credit deduction. 49. The billing history MUST be scoped to the individual subscription being viewed — the system MUST NOT display entries from other subscriptions. 50. The billing history MUST be ordered by date descending (newest - first). When there are more than 25 entries, the system MUST - paginate or provide a "show more" mechanism. + first). Users MUST be able to access additional entries when the + complete history is not shown initially. ### Payment Method Management @@ -379,8 +404,8 @@ pending settlement rather than fully active. ### Responsiveness 53. The Subscription Center MUST be fully functional on mobile - viewports. Subscription Cards MUST stack vertically on narrow - screens. + viewports without hiding subscription information or management + actions required by this spec. 54. All management actions on detail pages MUST be accessible and usable on mobile viewports. @@ -412,8 +437,7 @@ The following rules use SHOULD and reflect intended behavior that is not yet enforced in the current codebase: 1. The system SHOULD support additional subscription types beyond the - initial four. The group-based layout SHOULD accommodate new product - types without structural changes to the page. + initial four without disrupting access to existing products. 2. The system SHOULD surface upcoming renewals or billing events on the landing page (e.g. "renews in 3 days") to help users @@ -425,6 +449,27 @@ not yet enforced in the current codebase: ## Changelog +### 2026-05-28 -- Personal product navigation and return context + +- Defined access to each personal product and restoration of product context after detail-page navigation. +- Kept independent loading, terminal history, and available-product behavior within each product view. + +### 2026-05-27 -- Token Plan Plus pilot operations and UI behavior + +- Accepted billing-sweep local cleanup timing for the pilot and defined admin-console manual credential revocation. +- Added admin-only explicit key reveal, validate-on-upload inventory, mutation-time BYOK warnings, no prepaid extensions, and local-time grace display. +- Dropped Coding Plans admin-action audit history for the initial pilot while retaining secret-handling restrictions and inventory disposition state. + +### 2026-05-27 -- Coding Plans manual MiniMax revocation handling + +- Recorded manual provider revocation as the initial MiniMax operational workflow. +- Kept local cleanup separate from upstream revocation processing by authorized support. + +### 2026-05-27 -- Coding Plans ordinary MiniMax BYOK setup + +- Replaced read-only managed-key behavior with automatic ordinary MiniMax BYOK setup and normal key management. +- Defined occupied-MiniMax purchase blocking, billing separation, and conditional installed-key cleanup. + ### 2026-05-12 -- KiloClaw price-version display behavior - Added KiloClaw Subscription Card, detail, plan-switch, and Available diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000000..d662e5fb88 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,933 @@ +# Token Plan Plus (Coding Plans) - implementation revision plan + +## Outcome + +Replace the branch-local Coding Plans implementation with a code-configured catalog and an initial MiniMax offering named **Token Plan Plus**. A subscriber pays `$20` in Kilo Credits for each 30-day period and receives Kilo Gateway access through a plan-managed BYOK entry. The raw MiniMax credential is not exposed to the user. When access terminates, Kilo disables local access and revokes the credential with MiniMax. + +This is no longer a backend-only effort: initial product behavior requires catalog, purchase, status, cancellation, `past_due`, and managed-BYOK messaging in the personal Subscription Center. Raw credential viewing and copying are expressly out of scope. + +## Accepted decisions + +| Decision | Choice | +|---|---| +| Catalog contract | Backend-configured catalog; no minimum provider count | +| Initial offering | MiniMax `Token Plan Plus` only | +| Plan ID | `minimax-token-plan-plus` | +| Managed routing ID | `minimax-token-plan-plus-managed`, separate from plan ID and ordinary `minimax` BYOK | +| Price | `$20` per 30-day period (`20_000_000` internal microdollars) | +| Pricing configuration | Hardcoded in code; no coding-plan price environment variables | +| User credential access | Read-only managed BYOK entry; no raw-key view, copy, edit, disable, or delete | +| Routing isolation | Reuse BYOK plumbing with managed provenance and lifecycle enforcement | +| Initial activation | Atomic guarded debit and provisioning with explicit request idempotency key | +| User cancellation | Effective at paid-period end, then upstream revocation | +| Unfunded renewal with auto-top-up | One opted-in attempt plus up to 24-hour `past_due` grace | +| Unfunded renewal without auto-top-up | Terminate and begin revocation at renewal time | +| Issued keys | Terminal inventory lifecycle; never recycled | +| Re-subscription after termination | New subscription episode and new credential | +| Branch strategy | Rewrite existing unshipped branch implementation and regenerate clean migration | + +## Current branch state to replace + +The branch already introduced a different Coding Plans backend. These additions are not on `origin/main`, so they should be revised in place rather than supported as legacy behavior. + +| Existing branch work | Current behavior | Required revision | +|---|---|---| +| `packages/db/src/schema.ts` and migration `packages/db/src/migrations/0144_white_blob.sql` | Provider-keyed tables with reusable row lifecycle and no revocation state | Replace with plan identity, managed credential states, `past_due`, term history, and managed BYOK provenance | +| `apps/web/src/lib/coding-plans/pricing.ts` | Env-priced BytePlus/Kimi/Z.AI catalog | Replace with code-owned MiniMax Token Plan Plus entry at `20_000_000` microdollars | +| `apps/web/src/lib/coding-plans/index.ts` | Assigns ordinary BYOK credentials; no request idempotency input; no upstream revocation | Implement managed credential activation, immutable episodes, guarded debit, and termination pipeline | +| `apps/web/src/lib/coding-plans/billing-lifecycle-cron.ts` | Immediate insufficient-funds cancellation after one attempted top-up marker | Implement `past_due` grace and revocation retry state machine | +| `apps/web/src/routers/coding-plans-router.ts` | Arbitrary `string` provider inputs | Use Plan ID validation and new status/catalog contract | +| `apps/web/src/lib/user/index.ts` | Cancels subscription and deletes BYOK entry only | Start immediate provider revocation and anonymize inventory linkage | +| Existing tests | Exercise old providers and old lifecycle | Replace with MiniMax managed-credential and payment-recovery coverage | + +## Launch prerequisites + +These items must be settled before releasing subscriptions to users: + +1. Confirm MiniMax Token Plan Plus API endpoint, supported model identifiers, and required request transformation for gateway routing. +2. Confirm an upstream credential-revocation mechanism available to Kilo. Effective cancellation and account deletion cannot comply with `.specs/coding-plans.md` unless Kilo can revoke the assigned credential or reliably initiate revocation and retry failures. +3. Store any MiniMax management authorization as a protected server secret/binding according to existing secret-management conventions. This is distinct from plan pricing; pricing remains hardcoded. +4. Confirm the pricing-layer conversion used to display `$20` of internal accounting as Kilo Credits in the catalog, checkout, subscription detail, and billing history surfaces. + +--- + +## Step 1: Catalog and identity model + +### 1a. Code-owned catalog + +Replace `apps/web/src/lib/coding-plans/pricing.ts` with a code-owned catalog. Product identity is separate from credential routing identity. + +```typescript +export const CODING_PLAN_IDS = ['minimax-token-plan-plus'] as const; +export type CodingPlanId = (typeof CODING_PLAN_IDS)[number]; + +export type CodingPlanCatalogEntry = { + planId: CodingPlanId; + providerName: string; + name: string; + managedProviderId: 'minimax-token-plan-plus-managed'; + costMicrodollars: number; + billingPeriodDays: number; +}; + +export const CODING_PLAN_CATALOG = { + 'minimax-token-plan-plus': { + planId: 'minimax-token-plan-plus', + providerName: 'MiniMax', + name: 'Token Plan Plus', + managedProviderId: 'minimax-token-plan-plus-managed', + costMicrodollars: 20_000_000, + billingPeriodDays: 30, + }, +} satisfies Record; +``` + +Rules: + +- tRPC inputs use a schema derived from `CODING_PLAN_IDS`, not `z.string()`. +- Internal storage and credit transactions use microdollars; API/UI presentation converts to Kilo Credits through the existing pricing layer. +- Remove all `CODING_PLAN_PRICE_*` handling and old BytePlus/Kimi/Z.AI catalog test fixtures. + +### 1b. Managed routing identity + +Add a dedicated managed credential routing ID, `minimax-token-plan-plus-managed`, without treating it as the Plan ID or standard `minimax` user BYOK ID. + +Files to revise: + +- `apps/web/src/lib/ai-gateway/providers/openrouter/inference-provider-id.ts` +- `apps/web/src/lib/ai-gateway/providers/direct-byok/direct-byok-definitions.ts` +- `apps/web/src/lib/ai-gateway/providers/direct-byok/direct-byok-meta.ts` +- New MiniMax managed provider definition under `apps/web/src/lib/ai-gateway/providers/direct-byok/` +- Any direct-provider model-sync or static-model registration required by the confirmed MiniMax endpoint + +Routing invariants: + +- A standard user-supplied `minimax` BYOK key never grants Token Plan Plus entitlement. +- A `minimax-token-plan-plus-managed` credential is usable only while linked to a non-terminal entitled subscription. +- Shared BYOK gateway plumbing may execute requests, but plan-managed credential selection, attribution, and mutation policy remain separate. + +--- + +## Step 2: Data model and clean migration + +Revise `packages/db/src/schema.ts`, then regenerate the branch-local migration. Do not edit generated SQL or migration snapshot content by hand. + +### 2a. Managed BYOK provenance + +Extend `byok_api_keys` so plan-assigned credentials can be recognized and protected independently from keys users supply themselves. + +Proposed additions: + +```text +byok_api_keys +├── management_source text NOT NULL DEFAULT 'user' ('user' | 'coding_plan') +``` + +Behavior: + +- Existing BYOK rows default to `user`. +- Coding Plan activation inserts `management_source = 'coding_plan'`. +- The subscription row links the managed BYOK row to its entitlement; BYOK mutations may query that link when constructing errors or status. +- Normal BYOK create/update/enable/disable/delete flows must reject mutation of `coding_plan` entries. + +### 2b. `coding_plan_key_inventory` + +Rework the branch-local inventory table around a terminal credential lifecycle. + +```text +coding_plan_key_inventory +├── id uuid PK +├── plan_id text NOT NULL +├── managed_provider_id text NOT NULL +├── encrypted_api_key jsonb NULL (cleared after successful revocation when no retry needs it) +├── credential_fingerprint text NOT NULL UNIQUE +├── status text NOT NULL ('available' | 'assigned' | 'revocation_pending' | 'revoked' | 'revocation_failed') +├── assigned_to_user_id text NULL FK->kilocode_users.id ON DELETE SET NULL +├── assigned_at timestamptz +├── revocation_requested_at timestamptz +├── revoked_at timestamptz +├── revocation_attempt_count integer NOT NULL DEFAULT 0 +├── last_revocation_error text NULL (sanitized; never raw secrets) +├── created_at timestamptz NOT NULL DEFAULT now() +├── updated_at timestamptz NOT NULL DEFAULT now() +``` + +Constraints and rules: + +- Available-key query filters `plan_id` and `status = 'available'`; it never infers availability from a null user association. +- Assignment changes `available` to `assigned` in the activation transaction using `FOR UPDATE SKIP LOCKED` or an equivalent atomic claim. +- No transition returns an issued key to `available`. +- `credential_fingerprint` is computed with a keyed, non-reversible fingerprint for duplicate-upload detection; raw key hashes must not become an offline disclosure aid. +- GDPR cleanup clears/anonymizes `assigned_to_user_id` after local access termination while retaining plan, state, timestamps, and non-secret audit evidence. +- Retain a terminal inventory row only for the required credential-audit retention period. After it expires, that row may be removed without destroying subscription billing history. + +### 2c. `coding_plan_subscriptions` + +Rework subscriptions as distinct episodes. A re-subscription after cancellation inserts a new row and obtains a new credential rather than reactivating a prior row. + +```text +coding_plan_subscriptions +├── id uuid PK +├── user_id text NOT NULL FK->kilocode_users.id ON DELETE CASCADE +├── plan_id text NOT NULL +├── managed_provider_id text NOT NULL +├── key_inventory_id uuid FK->coding_plan_key_inventory.id ON DELETE SET NULL +├── managed_byok_key_id uuid FK->byok_api_keys.id ON DELETE SET NULL +├── status text NOT NULL ('active' | 'past_due' | 'canceled') +├── cost_microdollars bigint NOT NULL +├── billing_period_days integer NOT NULL +├── current_period_start timestamptz NOT NULL +├── current_period_end timestamptz NOT NULL +├── credit_renewal_at timestamptz NOT NULL +├── cancel_at_period_end boolean NOT NULL DEFAULT false +├── past_due_started_at timestamptz +├── payment_grace_expires_at timestamptz +├── auto_top_up_attempted_for_due timestamptz +├── canceled_at timestamptz +├── cancellation_reason text +├── created_at timestamptz NOT NULL DEFAULT now() +├── updated_at timestamptz NOT NULL DEFAULT now() +``` + +Constraints and rules: + +- Use a partial unique index allowing at most one `active` or `past_due` subscription for `(user_id, plan_id)`. +- Require `key_inventory_id` and `managed_byok_key_id` while a subscription is non-terminal. A canceled subscription may lose those references only after access has ended and applicable credential-record retention permits deletion. +- Canceled rows remain immutable subscription history and do not block a new subscription episode. +- `cancellation_reason` distinguishes user cancellation, insufficient credits, account deletion, and administrative termination. + +### 2d. Charged terms and idempotency + +Add a charged-term record rather than relying on mutable subscription dates or calendar-month ledger categories to represent purchases. + +```text +coding_plan_terms +├── id uuid PK +├── subscription_id uuid NOT NULL FK->coding_plan_subscriptions.id +├── user_id text NOT NULL FK->kilocode_users.id +├── plan_id text NOT NULL +├── kind text NOT NULL ('activation' | 'extension' | 'renewal') +├── idempotency_key text NOT NULL +├── period_start timestamptz NOT NULL +├── period_end timestamptz NOT NULL +├── cost_microdollars bigint NOT NULL +├── credit_transaction_id uuid NOT NULL FK->credit_transactions.id +├── created_at timestamptz NOT NULL DEFAULT now() +``` + +Constraints and rules: + +- Unique `(user_id, plan_id, idempotency_key)` prevents duplicate successfully committed user-requested charges. +- A failed precondition or rolled-back activation does not reserve a credential or charge; retrying after correcting balance or capacity may succeed under the same request key because no outcome previously committed. +- Scheduled renewal derives a deterministic idempotency key from subscription and due period; repeated cron work cannot double-charge one term. +- Persist a fixed-size representation or hash of client-provided keys if existing idempotency conventions require it. +- Billing history queries are scoped through `subscription_id` rather than parsing descriptions. + +### 2e. Migration workflow + +Existing Coding Plans schema and migration are branch-local and unshipped. Replace rather than extend them: + +1. Change `packages/db/src/schema.ts` to the final model. +2. Remove branch-added Coding Plans migration artifacts, including `packages/db/src/migrations/0144_white_blob.sql`, its snapshot, and its branch-added journal entry as required by repository migration guidance. +3. Run `pnpm drizzle generate` to create one clean migration from `origin/main` schema to the final Coding Plans schema. +4. Apply migrations in the local test database before running database-backed tests. + +--- + +## Step 3: Credential inventory administration + +Implement inventory functions in `apps/web/src/lib/coding-plans/` and expose them through admin procedures in `apps/web/src/routers/coding-plans-router.ts`. + +Admin functions: + +| Operation | Input | Behavior | +|---|---|---| +| Upload credentials | `{ planId: CodingPlanId, keys: string[] }` | Encrypt keys, create keyed fingerprints, reject duplicates, insert `available` inventory rows | +| Inventory counts | `{ planId?: CodingPlanId }` | Return counts grouped by lifecycle status; never return encrypted or raw key data | +| Retry revocation | `{ inventoryKeyId: string }` | Reattempt upstream revocation only for pending/failed terminal credentials | +| Immediate termination | `{ subscriptionId: string }` | Disable local access immediately and enqueue/start revocation | + +Security rules: + +- Only `adminProcedure` may upload credentials or invoke remediation. +- Do not log keys, encrypted payloads, authorization headers, or provider management secrets. +- Error messages may include Plan ID, subscription ID, inventory ID, or status; not credential content. + +--- + +## Step 4: Atomic purchase and extension flow + +Implement in `apps/web/src/lib/coding-plans/index.ts`. + +### 4a. `subscribeToCodingPlan(userId, planId, idempotencyKey)` + +For a new subscription episode: + +1. Resolve `planId` from `CODING_PLAN_CATALOG`; reject unknown or unavailable plans. +2. Look up an existing charged term for `(userId, planId, idempotencyKey)` before interpreting an active subscription as an extension; return its existing subscription result on retry. +3. If the user has an active subscription and this is a new deliberate purchase, use the extension flow. If the subscription is `past_due` or pending cancellation, reject with a specific recovery/resume error unless a separately designed resume action applies. +4. In one transaction: + - Atomically debit `20_000_000` microdollars only if available balance remains sufficient at update time. + - Insert the credit transaction with category tied to the idempotent charged term. + - Atomically claim one `available` inventory credential for `planId` and set it to `assigned`. + - Insert a `coding_plan` managed BYOK entry using `managedProviderId`. + - Insert an `active` subscription episode linked to inventory and BYOK entries. + - Insert its activation term with the request idempotency key and period snapshot. +5. On insufficient credits, no inventory, duplicate live subscription race, or any insertion failure, roll the transaction back. +6. After successful commit, perform any existing best-effort Kilo Pass usage-bonus evaluation without changing activation success. + +Concurrency requirements: + +- Guard balance debit within the transaction; a pre-transaction balance read is not authorization to spend. +- Claim inventory through row locking or atomic update. +- Enforce one live subscription through the partial unique index. +- Retry of identical request returns the previously committed term/subscription and does not extend it. + +### 4b. Deliberate paid extension + +If the API continues to permit pre-purchasing another period for an active subscription: + +1. Require a new idempotency key. +2. Atomically debit credits and insert an `extension` charged term. +3. Stack its period onto `current_period_end`. +4. Keep the same assigned managed credential. +5. Do not implicitly undo a pending cancellation; require an explicit resume rule before exposing this behavior in UI. + +An initial UI does not need to expose pre-purchase extension unless product requires it; recurring renewal is required. + +--- + +## Step 5: Managed BYOK behavior and MiniMax routing + +### 5a. BYOK mutation restrictions + +Update backend and UI behavior around BYOK entries: + +- `apps/web/src/routers/byok-router.ts` must reject update, enable/disable, and delete mutations for entries with `management_source = 'coding_plan'`. +- BYOK list responses may identify a managed entry and its plan display name, but must never add a raw-key reveal field. +- `apps/web/src/components/organizations/byok/BYOKKeysManager.tsx` must show managed Coding Plan entries as read-only and must not claim that those entries are user-supplied or billed directly by MiniMax. +- A managed BYOK entry cannot be replaced by a standard user-created key under its managed routing ID. + +### 5b. Routing entitlement check + +When a request selects a Token Plan Plus managed model/provider route: + +1. Load the managed BYOK credential only when it is linked to a subscription in `active` or unexpired `past_due` state. +2. Do not fall back to a standard `minimax` BYOK credential for a Token Plan Plus request. +3. Attribute usage to the Coding Plan route and preserve obfuscated identity handling required for upstream traffic. +4. Reject traffic after local termination even while upstream revocation is pending or failed. + +### 5c. Provider revocation adapter + +Add a MiniMax lifecycle adapter under `apps/web/src/lib/coding-plans/providers/` or the established equivalent location once provider API conventions are confirmed. + +Required operation: + +```typescript +revokeManagedCredential(inventoryKeyId: string): Promise<'revoked' | 'retryable_failure'> +``` + +The adapter must: + +- Read/decrypt a credential only server-side and only for a required MiniMax management request. +- Redact provider secrets and credential values from logging. +- Record successful revocation or sanitized retry failure without restoring local access. +- Be callable from termination handling and retry cron/remediation tooling. + +--- + +## Step 6: Billing lifecycle and termination + +Rewrite `apps/web/src/lib/coding-plans/billing-lifecycle-cron.ts` and revise `apps/web/vercel.json` only if schedule precision must change to meet product timing. + +### 6a. User-requested cancellation + +`cancelCodingPlanSubscription(userId, subscriptionId)`: + +1. For an `active` subscription, set `cancel_at_period_end = true`. +2. Keep managed BYOK access through `current_period_end`. +3. At period end, transition subscription to `canceled`, disable/remove local managed BYOK access, set inventory to `revocation_pending`, and initiate MiniMax revocation. +4. Do not trigger auto-top-up for a cancellation scheduled by the user. + +### 6b. Funded renewal + +For an `active` subscription due for renewal without scheduled cancellation: + +1. Derive deterministic renewal term idempotency from subscription and due period. +2. In one transaction, guard/debit the snapshotted price, insert renewal credit transaction and term, advance period dates, and clear payment-recovery markers. +3. Keep the same managed credential assigned and usable. + +### 6c. Unfunded renewal without auto-top-up + +At renewal time: + +1. If sufficient credits cannot be atomically debited and eligible auto-top-up is disabled, terminate local access immediately. +2. Mark the subscription `canceled` with reason `insufficient_credits`. +3. Start the revocation pipeline. + +### 6d. Unfunded renewal with opted-in auto-top-up + +At renewal time: + +1. If debit fails for insufficient credits and auto-top-up is eligible, set status to `past_due`, set `payment_grace_expires_at = now() + 24 hours`, and record at most one attempted top-up for this due term. +2. Initiate auto-top-up and keep managed BYOK access available during the unexpired grace period. +3. On later sweep, if credits are now sufficient, perform funded renewal atomically and restore `active` status. +4. If grace expires before funded renewal commits, disable local access and start revocation. + +### 6e. Revocation retries + +A separate sweep or shared lifecycle sweep processes `revocation_pending` and `revocation_failed` credentials: + +- On success: mark `revoked`, set `revoked_at`, and clear encrypted secret material when safe. +- On retryable failure: mark `revocation_failed`, retain encrypted material only as needed to retry, increment attempt count, and alert/queue remediation under existing observability patterns. +- Never re-enable local access or return a credential to available inventory because revocation failed. + +--- + +## Step 7: Account deletion and privacy + +Update `apps/web/src/lib/user/index.ts` and `apps/web/src/lib/user/index.test.ts`. + +For account soft-delete: + +1. Find every non-terminal Coding Plan subscription for the user. +2. Immediately disable/remove associated managed BYOK entries, regardless of paid-through date. +3. Set subscriptions to `canceled` with reason `account_deleted` and clear operational payment-recovery flags. +4. Set associated inventory credentials to `revocation_pending` and initiate/retry upstream revocation outside the deletion transaction if an external request cannot be safely transactional. +5. Clear or anonymize direct inventory linkage to the deleted user while retaining non-secret state/timestamp evidence needed to prove credential disposition. +6. Subscription and charged-term rows may remain linked to the existing anonymized `kilocode_users` record for financial history under established retention policy; inventory must not retain a directly attributable subscriber link after cleanup. +7. Preserve the global rule that no raw credential or PII is sent to logs or MiniMax revocation telemetry. + +--- + +## Step 8: API and user surfaces + +### 8a. tRPC router + +Revise `apps/web/src/routers/coding-plans-router.ts` and its registration as needed. + +| Endpoint | Input | Output/behavior | +|---|---|---| +| `catalog` | none | Configured entries with user-facing Kilo Credits cost and billing period | +| `listSubscriptions` | none | Owned episodes including `active`, `past_due`, and terminal history | +| `subscribe` | `{ planId: CodingPlanId, idempotencyKey: string }` | Atomic activation or idempotent prior result | +| `cancel` | `{ subscriptionId: string }` | Schedule end-of-paid-period cancellation for owned active subscription | +| `adminKeyInventory` | `{ planId?: CodingPlanId }` | Non-secret lifecycle counts | +| `adminUploadKeys` | `{ planId: CodingPlanId, keys: string[] }` | Encrypted deduplicated inventory insert | +| `adminTerminateSubscription` | `{ subscriptionId: string }` | Immediate local termination plus revocation pipeline | +| `adminRetryRevocation` | `{ inventoryKeyId: string }` | Retry failed/pending upstream revocation | + +### 8b. Subscription Center and managed BYOK UI + +Align UI behavior with `.specs/subscription-center.md` and `.specs/coding-plans.md`: + +- Complete the Coding Plans group and detail surface under `apps/web/src/components/subscriptions/coding-plans/` and corresponding route. +- Display MiniMax Token Plan Plus Available Product Card when it is configured and the user has no live subscription for that Plan ID. +- Display `active`, cancellation-scheduled, `past_due` with grace deadline, and `canceled` states. +- Show Kilo Credits pricing, paid-through/renewal date, and credit-funded billing history. +- Offer purchase and cancellation actions; cancellation copy states that credential access ends and is revoked at paid-period end. +- Surface managed BYOK presence as read-only; do not display or copy the raw credential. + +Before implementing visual changes under `apps/web`, read `design.md` and load the repository `kilo-design` skill as required by repository guidance. + +--- + +## Step 9: Tests and validation + +### Core and database tests + +Update or replace `apps/web/src/lib/coding-plans/index.test.ts` to cover: + +1. Catalog contains MiniMax Token Plan Plus at `20_000_000` microdollars and maps to user-facing Kilo Credits. +2. Activation creates one active subscription, one activation term, one assigned inventory key, and one managed BYOK entry. +3. Explicit idempotency retry returns prior result without another debit, term, or key assignment. +4. Two concurrent activation attempts cannot overspend credits or create two live subscriptions. +5. Insufficient balance commits no term, debit, subscription, or assignment. +6. Empty inventory commits no term, debit, subscription, or assignment. +7. Duplicate uploaded key is rejected through safe fingerprint comparison. +8. Deliberate extension with new idempotency key charges and extends exactly once, if supported. +9. Re-subscription after terminal cancellation creates a new episode and assigns a new key. + +### Managed BYOK and routing tests + +Add coverage for: + +10. Managed entry cannot be edited, disabled, deleted, or raw-key-revealed through ordinary BYOK endpoints. +11. Standard user-supplied `minimax` key does not satisfy Token Plan Plus routing. +12. Token Plan Plus managed route works only while linked subscription is `active` or within valid `past_due` grace. +13. Locally terminated subscription cannot route traffic while upstream revocation is pending or failed. + +### Billing and revocation tests + +Add `apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts` or equivalent coverage for: + +14. Successful renewal charges one term and keeps assigned credential. +15. User scheduled cancellation terminates only at paid-period end and starts revocation without auto-top-up. +16. Insufficient renewal without auto-top-up terminates and starts revocation immediately. +17. Eligible auto-top-up creates one `past_due` grace period and one top-up attempt. +18. Credits arriving within grace renew and restore `active` state. +19. Grace expiry terminates and starts revocation. +20. Successful revocation marks terminal inventory state and clears secret material when allowed. +21. Failed revocation keeps local access disabled and remains retryable. + +### GDPR, router, and UI tests + +Add or update coverage for: + +22. Soft-delete immediately removes managed BYOK access, terminalizes subscription, anonymizes inventory linkage, and starts revocation. +23. Router inputs reject unknown Plan IDs and require activation idempotency keys. +24. Admin inventory responses never expose encrypted or raw key material. +25. Subscription Center renders configured offering, status/grace/cancellation messages, and no raw-key controls. + +### Verification commands during implementation + +Run the narrowest applicable checks as work is completed: + +- Start or verify Postgres before database-backed tests: `docker compose -f dev/docker-compose.yml ps postgres` and `pnpm test:db` if needed. +- Run targeted test files with `pnpm test -- `. +- Run affected-package type checks or `scripts/typecheck-all.sh --changes-only` rather than defaulting to full repository typecheck. +- Run `pnpm format` before committing any implementation changes. +- Run the Markdown table-padding check for changes to this plan or the governing specs. + +--- + +## Implementation order + +| Phase | Work | Primary files | +|---|---|---| +| 1 | Confirm MiniMax routing and revocation contract | Provider documentation and approved secret/binding configuration | +| 2 | Finalize catalog and identifier types | `apps/web/src/lib/coding-plans/pricing.ts`, provider ID/type files | +| 3 | Replace schema and regenerate branch migration | `packages/db/src/schema.ts`, `packages/db/src/migrations/` | +| 4 | Add MiniMax managed provider and entitlement routing | `apps/web/src/lib/ai-gateway/providers/`, BYOK retrieval/routing files | +| 5 | Implement inventory, activation, term idempotency, extension | `apps/web/src/lib/coding-plans/index.ts` | +| 6 | Protect managed BYOK entries | `apps/web/src/routers/byok-router.ts`, BYOK types/UI | +| 7 | Implement renewal grace, cancellation, and revocation retries | `apps/web/src/lib/coding-plans/billing-lifecycle-cron.ts`, cron route/config | +| 8 | Complete GDPR deletion lifecycle | `apps/web/src/lib/user/index.ts` and tests | +| 9 | Revise tRPC APIs and Subscription Center surfaces | `apps/web/src/routers/coding-plans-router.ts`, subscription components/routes | +| 10 | Replace/add tests and run focused validation | Coding Plan, BYOK, lifecycle, user, router, and UI tests | + +## Configuration summary + +| Configuration item | Requirement | +|---|---| +| Token Plan Plus price | Hardcoded as `20_000_000` microdollars per 30 days | +| Plan ID | `minimax-token-plan-plus` | +| Managed routing ID | `minimax-token-plan-plus-managed` | +| Price environment variables | None | +| MiniMax revocation authorization | Protected server secret/binding required once provider integration is confirmed | +| Credential encryption | Reuse approved server-side BYOK encryption handling | +| Credential fingerprinting | Keyed non-reversible fingerprint for inventory deduplication | + +## Explicitly excluded from initial release + +- Raw MiniMax key viewing, copying, export, or direct use by subscribers. +- Ordinary user mutation of plan-managed BYOK entries. +- Additional configured Coding Plan offerings beyond MiniMax Token Plan Plus. +- Compatibility support for the branch-local BytePlus/Kimi/Z.AI and environment-priced implementation being replaced. + +--- + +## Addendum A: implementation review findings (2026-05-27) + +This addendum records gaps found by reviewing the current implementation against this plan, `.specs/coding-plans.md`, and the Coding Plans rules in `.specs/subscription-center.md`. The feature is not ready for release until the blocking items are resolved. This review inspected code only; it did not execute tests or application commands. + +### Blocking gaps + +| Finding | Evidence in current implementation | Required correction | +|---|---|---| +| No production upstream revocation path | `apps/web/src/lib/coding-plans/revocation.ts` accepts an injected revoker, but production cancellation, renewal termination, account deletion, and admin retry only mark credentials `revocation_pending`; calls to `processCredentialRevocation` exist only in tests. | Add a production MiniMax revocation adapter, invoke it from effective termination and account deletion handling, and process pending/failed credentials through retry sweep or remediation tooling. | +| Access termination is not enforced at the paid-period or grace boundary | `apps/web/vercel.json` schedules lifecycle processing hourly; `apps/web/src/lib/ai-gateway/byok/index.ts` permits any `active` subscription without checking `current_period_end`; `apps/web/src/lib/coding-plans/billing-lifecycle-cron.ts` calculates grace as sweep time plus 24 hours. | Gate routing by effective access end, define renewal/grace boundaries from the due timestamp, and use scheduling or request-time enforcement that cannot extend canceled or unfunded access past its allowed deadline. | +| Managed BYOK UI still treats managed credentials as ordinary user keys | `apps/web/src/components/organizations/byok/BYOKKeysManager.tsx` has no `management_source` handling, presents all keys as user-supplied/provider-billed, includes managed provider IDs in creation choices, and renders enable, test, edit, and delete controls for managed entries. | Render Coding Plan managed entries as read-only, remove forbidden actions and ordinary BYOK billing copy, and prevent users from selecting managed provider IDs in create flows. | + +These gaps contradict `.specs/coding-plans.md` requirements for effective revocation, exact lifecycle termination, and plan-managed read-only credentials, including sections 3.5, 5.2, 5.4-5.9, 6.2-6.3, and 7.3-7.5. + +### Correctness gaps + +| Finding | Evidence in current implementation | Required correction | +|---|---|---| +| Administrative revocation retry does not perform a retry | `apps/web/src/routers/coding-plans-router.ts` delegates `adminRetryRevocation` to `queueCredentialRevocationRetry`, which only changes inventory status in `apps/web/src/lib/coding-plans/index.ts`. | Make admin retry call the provider adapter or enqueue an executable retry job whose processing and result are visible. | +| Extension can race with cancellation scheduling | `apps/web/src/lib/coding-plans/index.ts` checks `cancel_at_period_end` before the extension update, then updates by subscription ID without a guarded `cancel_at_period_end = false` condition. | Lock or conditionally update the subscription so a concurrent cancellation cannot receive a newly charged extension without an explicit resume action. | +| Terminal episodes lose managed BYOK linkage immediately | Immediate termination, period-end cancellation, renewal-failure termination, and account deletion null `managed_byok_key_id` and delete the BYOK row as soon as access ends. | Preserve retained non-secret episode-to-managed-entry evidence through the required audit retention window, or revise the data model to retain equivalent immutable evidence before deleting the operational BYOK entry. | +| Canceled detail view omits read-only credential notice | `apps/web/src/components/subscriptions/coding-plans/CodingPlanDetail.tsx` suppresses the managed read-only/raw-key notice for `canceled` subscriptions. | Keep managed credential provenance and raw-key unavailability explicit in terminal history views. | + +### Release gates still open + +- Confirm MiniMax Token Plan Plus API endpoint, model identifiers, and required request transformation. Current implementation enables the UI and registers `https://api.minimax.io/v1` with `MiniMax-M2.7` without repository evidence that the provider contract has been approved. +- Confirm and implement MiniMax credential revocation plus protected management authorization. No production revocation adapter or management secret integration is present. +- Do not expose the feature to users while the production revocation and exact access-termination gaps remain unresolved; `apps/web/src/lib/constants.ts` currently enables Coding Plan subscriptions globally. + +### Ambiguities to resolve before adding offerings or finalizing retention + +| Topic | Ambiguity | Decision required | +|---|---|---| +| Multi-plan catalog visibility | This plan requires an Available Product Card when the user has no live subscription for a given Plan ID, while `.specs/subscription-center.md` describes showing catalog cards when the Coding Plans group has no non-terminal subscriptions. Current UI hides the full catalog when any live Coding Plan exists. | Specify whether available cards remain visible per unsubscribed Plan ID when another plan is active, then update spec and UI consistently. | +| Inventory subscriber-link anonymization | This plan says GDPR cleanup clears/anonymizes inventory user linkage after local access termination; `.specs/coding-plans.md` explicitly mandates anonymization on account deletion. Current implementation clears linkage only for account deletion. | State whether ordinary cancellation, failed renewal, and administrative termination must clear `assigned_to_user_id`, and define retained audit evidence. | + +### Coverage required before release + +- Add production-path tests proving effective cancellation, account deletion, cron retry, and administrative retry invoke the MiniMax revocation adapter and persist success/failure outcomes. +- Add routing tests at `current_period_end` and `payment_grace_expires_at` boundaries, including proof that the grace period cannot exceed 24 hours from the due renewal. +- Add a concurrent cancellation-versus-extension test proving a pending cancellation cannot be extended or charged accidentally. +- Add component coverage for managed BYOK read-only presentation, absence of raw-key/mutation controls, and terminal Coding Plan detail messaging. +- Add catalog UI coverage for the resolved multi-plan visibility rule before any second configured offering is introduced. + +### Already aligned implementation areas + +- Code-owned MiniMax catalog, Plan ID, managed routing ID, price, and billing period are present in `apps/web/src/lib/coding-plans/pricing.ts`. +- Schema includes managed provenance, terminal inventory states, live-subscription uniqueness, charged terms, and request idempotency in `packages/db/src/schema.ts`. +- Initial activation performs guarded debit, atomic inventory claim, managed BYOK creation, subscription creation, and charged-term insertion in one transaction in `apps/web/src/lib/coding-plans/index.ts`. +- Router Plan ID validation and activation idempotency key input are implemented in `apps/web/src/routers/coding-plans-router.ts`. +- Backend ordinary-BYOK mutations for managed entries are rejected in `apps/web/src/routers/byok-router.ts`; remaining gap is user-facing treatment and production lifecycle integration. + +--- + +## Addendum B: ordinary MiniMax BYOK integration direction (2026-05-27) + +This addendum records revised direction agreed after Addendum A. It supersedes earlier parts of this plan and Addendum A only where they require a separate managed provider identity, a read-only or restricted Coding Plan BYOK entry, or a plan-specific MiniMax routing namespace. The governing specs in `.specs/coding-plans.md` and `.specs/subscription-center.md` still describe the prior model and must be updated before revised implementation is considered compliant. + +### Revised decisions + +| Topic | Revised direction | +|---|---| +| Plan ID | Remains `minimax-token-plan-plus` | +| BYOK provider identity | Use existing personal BYOK provider ID `minimax`; do not add `minimax-token-plan-plus-managed` | +| Provider/model routing | Use ordinary existing MiniMax BYOK routing and model availability; do not introduce a subscribed-plan model namespace | +| Initial access setup | Kilo automatically installs a personal `minimax` BYOK configuration from the issued Token Plan Plus credential | +| Existing MiniMax key | Block purchase before confirmation when any personal `minimax` BYOK row already exists; instruct user to remove it from `/byok` first | +| Backend precondition | Activation must independently reject an occupied personal `minimax` BYOK slot before a debit, inventory assignment, or subscription is committed | +| BYOK functionality | Keep normal `/byok` update, test, enable/disable, and delete actions available for the installed MiniMax key | +| Billing after BYOK changes | Updating, disabling, or deleting the BYOK key does not cancel, pause, or otherwise alter Coding Plan subscription billing | +| Origin display | While the installed key is unchanged, `/byok` should identify it as configured by Token Plan Plus and state that BYOK changes do not cancel the subscription | +| End-of-plan cleanup | Remove the MiniMax BYOK row only if it is still the Kilo-installed configuration; preserve a user-replaced or recreated MiniMax key | +| Issued credential revocation | Always revoke the credential assigned from Coding Plan inventory when plan access terminates, whether or not the BYOK row was changed | +| Raw key disclosure | This revision does not add saved-key view or copy behavior; existing raw-credential secrecy remains required | + +### Superseded assumptions from the original plan + +| Prior plan section | Superseded assumption | Replacement | +|---|---|---| +| Accepted decisions; Step 1b; configuration summary | A dedicated provider ID `minimax-token-plan-plus-managed` represents plan routing | Use the existing `minimax` BYOK provider identity and ordinary MiniMax routing | +| Step 2a; Step 5a; explicit exclusions | A Coding Plan-installed BYOK row is read-only and ordinary BYOK mutations must be rejected | Normal BYOK functionality remains available; provenance is informational and supports cleanup ownership only | +| Step 2c | Every non-terminal subscription must retain a linked BYOK row | Subscription may remain active and billable after user deletes or replaces its configured BYOK key | +| Step 5b | Plan access is routed through a separate entitlement-gated provider path | Installed or user-replaced `minimax` routes as ordinary BYOK when present and enabled | +| Step 8b; Step 9 | Subscription UI and tests must prove read-only managed-key controls | UI and tests must explain automatic setup, ordinary management, and independent billing | +| Addendum A managed-BYOK UI blocker | Exposed ordinary actions are a release-blocking policy violation | Read-only controls are no longer required; missing origin/billing-separation explanation is the remaining UI requirement | + +### Revised activation flow + +`subscribeToCodingPlan(userId, planId, idempotencyKey)` keeps guarded billing, inventory assignment, charged terms, and immutable subscription episodes, with these changes: + +1. Before the purchase confirmation action, the Subscription Center queries whether the user already has a personal BYOK row with `provider_id = 'minimax'`. +2. If a MiniMax key exists, including a disabled key, the purchase surface does not proceed. It tells the user to remove the existing MiniMax key in `/byok` before subscribing. +3. The backend repeats this precondition in activation processing. A stale client or a race that finds an occupied MiniMax slot fails without committing a debit, credential assignment, subscription, or charged term. +4. If the slot is empty, activation atomically debits Kilo Credits, claims an available Token Plan Plus credential, creates a personal BYOK row with `provider_id = 'minimax'`, creates the active subscription episode, and records the activation term. +5. Activation marks the installed BYOK row with `management_source = 'coding_plan'` while it still contains Kilo's installed credential. This marker means "installed by this Coding Plan" for UI and cleanup ownership. It must not authorize restrictions on normal BYOK actions. +6. Activation does not add any new direct-BYOK provider definition or special MiniMax model ID. Traffic uses existing ordinary MiniMax BYOK behavior. + +### BYOK ownership and mutation behavior + +The `/byok` surface remains a normal key-management surface in the initial release. Special rules apply only to bookkeeping needed to avoid deleting a user's replacement key later. + +| User action on installed `minimax` key | BYOK result | Subscription result | Cleanup ownership result | +|---|---|---|---| +| Test | Test through ordinary MiniMax BYOK behavior | Unchanged | Row remains Kilo-installed | +| Disable or re-enable | Toggle key availability normally | Unchanged; billing continues | Row remains Kilo-installed because credential value is unchanged | +| Update credential value | Save replacement value normally | Unchanged; billing continues | Mark row user-managed and detach it from plan cleanup | +| Delete | Delete key normally | Unchanged; billing continues | Link clears; a later user-created MiniMax row is user-managed | +| Create MiniMax after prior delete | Create ordinary MiniMax key normally | Unchanged; billing continues | New row is user-managed and must survive plan termination | + +User-facing messaging must be explicit: changing or deleting the key affects MiniMax routing configuration, not Token Plan Plus billing. Subscription cancellation remains available through the Coding Plan detail surface. + +### Revised termination and revocation flow + +When cancellation becomes effective, renewal fails without recovery, administrative termination occurs, or account deletion requires immediate termination: + +1. Transition the Coding Plan subscription according to the lifecycle reason and stop future renewals where applicable. +2. If the subscription still references a BYOK row marked as installed by the Coding Plan, delete that specific row to stop access through Kilo's installed configuration. +3. If the linked row was replaced, detached, deleted, or followed by a user-created `minimax` key, do not delete the current user-managed MiniMax configuration. +4. Move the originally issued inventory credential into `revocation_pending` and run the upstream MiniMax revocation pipeline irrespective of current BYOK row state. +5. Failed upstream revocation must remain retryable and must never cause an issued credential to return to available inventory. +6. Account deletion continues to remove all user BYOK configuration under the general user-deletion policy and anonymizes inventory subscriber linkage as already required. + +### Schema and API consequences + +Because this branch has not shipped, schema and generated migration artifacts should describe final semantics rather than preserve dedicated-provider terminology. + +| Area | Required revision | +|---|---| +| `byok_api_keys.management_source` | Retain it to display install origin and protect cleanup ownership; do not use it to reject mutations | +| Installed-key link | Rename `managed_byok_key_id` to `installed_byok_key_id` and make it nullable for non-terminal subscriptions | +| Live-access constraint | Remove any constraint requiring a BYOK row while subscription is `active` or `past_due` | +| Provider columns | Rename `managed_provider_id` to `provider_id` and store the existing `minimax` provider identity | +| Inventory linkage | Preserve inventory credential linkage independently from mutable or deleted BYOK configuration so revocation targets Kilo's issued credential | +| Catalog/API output | Replace dedicated managed-provider output with ordinary `providerId: 'minimax'` where clients need provider identity | +| Migration | Regenerate the unshipped Coding Plans migration from updated schema instead of layering compatibility behavior onto the obsolete dedicated-provider model | + +### Required implementation changes + +| Change group | Primary files | Work | +|---|---|---| +| Remove dedicated provider | `apps/web/src/lib/coding-plans/pricing.ts`, `apps/web/src/lib/ai-gateway/providers/openrouter/inference-provider-id.ts`, `apps/web/src/lib/ai-gateway/providers/direct-byok/` | Remove `minimax-token-plan-plus-managed` identity, definition, metadata, and test-model registration | +| Restore ordinary routing | `apps/web/src/lib/ai-gateway/byok/index.ts` | Remove separate managed-provider entitlement lookup and preserve normal `minimax` BYOK retrieval for users and organizations | +| Add activation precondition | `apps/web/src/lib/coding-plans/index.ts`, `apps/web/src/routers/coding-plans-router.ts`, Coding Plan subscribe UI | Warn before purchase and reject activation atomically when user's `minimax` slot is occupied | +| Remove BYOK restrictions | `apps/web/src/routers/byok-router.ts`, `apps/web/src/components/organizations/byok/BYOKKeysManager.tsx` | Remove special mutation rejection and obsolete provider filtering; add informational origin/billing-separation note | +| Track ownership transfer | `apps/web/src/routers/byok-router.ts`, `apps/web/src/lib/coding-plans/index.ts`, schema | On replacement, transfer the BYOK row to user management and detach cleanup ownership without changing billing | +| Revise lifecycle cleanup | `apps/web/src/lib/coding-plans/index.ts`, `apps/web/src/lib/coding-plans/billing-lifecycle-cron.ts`, `apps/web/src/lib/user/index.ts` | Remove only untouched installed row; always revoke issued inventory credential | +| Revise Subscription Center copy | `apps/web/src/components/subscriptions/coding-plans/` | Replace read-only messaging with automatic setup and separate-cancellation messaging | +| Regenerate data model | `packages/db/src/schema.ts`, `packages/db/src/schema-types.ts`, `packages/db/src/migrations/` | Encode nullable installed-key association and ordinary provider identity in one clean branch-local migration | + +### Required test changes + +Replace tests that enforce the superseded dedicated-provider/read-only behavior with coverage for the revised contract: + +1. Catalog and subscription API identify MiniMax Token Plan Plus without a `minimax-token-plan-plus-managed` provider identity. +2. Successful activation installs a personal BYOK row with `provider_id = 'minimax'` and preserves inventory/term/idempotency guarantees. +3. Purchase UI warns when a personal MiniMax BYOK key already exists, and backend activation rejects the same condition without charging or assigning inventory. +4. Ordinary MiniMax routing uses the automatically installed key without a dedicated direct-provider model route. +5. `/byok` allows test, enable/disable, update, and delete for an installed MiniMax key. +6. Disabling or deleting the installed key leaves the Coding Plan subscription active and does not prevent later renewal charging. +7. Updating an installed key transfers BYOK cleanup ownership to the user while leaving subscription and inventory state intact. +8. Effective cancellation removes an untouched installed key, starts revocation of the issued inventory credential, and stops renewal. +9. Effective cancellation preserves a user-replaced or recreated MiniMax key while still revoking Kilo's originally issued inventory credential. +10. Account deletion removes local BYOK access, terminalizes the subscription, anonymizes inventory linkage, and starts revocation as required by privacy rules. +11. Subscription Center and `/byok` copy state that Kilo automatically configures MiniMax, key management is available in `/byok`, and deleting or changing a key does not cancel subscription billing. + +### Spec updates required before implementation acceptance + +The revised direction conflicts with normative language currently in the governing specs. Update these requirements before treating the resulting implementation as conforming: + +| Spec location | Required change | +|---|---| +| `.specs/coding-plans.md` definitions and sections 3.3-3.5 | Replace read-only managed-entry requirement with ordinary provider setup, origin display, and billing-separation rules | +| `.specs/coding-plans.md` sections 5.2, 5.4-5.9 | State that termination removes only unchanged installed configuration while always revoking Kilo-issued credential | +| `.specs/coding-plans.md` sections 6.1-6.3 | Remove separate provider/entitlement namespace; require existing MiniMax BYOK routing and safe collision handling | +| `.specs/coding-plans.md` sections 7.3-7.5 | Replace read-only notice with origin, BYOK management, and independent-billing messaging | +| `.specs/subscription-center.md` Coding Plans rules | Replace managed-read-only copy and cleanup wording with automatic configuration and ownership-transfer behavior | + +### Release gates retained from Addendum A + +The revised BYOK policy removes the prior read-only-UI blocker, but does not remove lifecycle and provider-safety requirements: + +- Implement and verify a production MiniMax credential revocation path, including retry/remediation behavior. +- Enforce effective cancellation and `past_due` expiration at their intended deadlines, rather than relying on a delayed sweep that extends access. +- Confirm ordinary MiniMax BYOK routing supports the Token Plan Plus credential and intended supported models before enabling the offering for users. +- Keep raw issued credentials and provider-management secrets out of UI responses, API responses, logs, analytics, and monitoring. + +--- + +## Addendum C: remaining implementation work for initial pilot (2026-05-27) + +This addendum records the implementation work still required for the initial Token Plan Plus pilot after reviewing the branch and resolving open product decisions. It supersedes earlier plan and Addendum A language that requires a dedicated managed provider identity, read-only BYOK behavior, automated MiniMax revocation, strict request-time access cutoff, a separate purchase feature flag, prepaid extensions, or Coding Plans admin-action audit history. Addendum B remains authoritative for ordinary MiniMax BYOK setup and ownership transfer except where this addendum provides more specific pilot behavior. + +### Accepted pilot direction + +| Topic | Decision | +|---|---| +| Deployment gate | Do not deploy until pilot implementation is complete and validated MiniMax credential inventory is loaded; no separate in-product purchase flag is required | +| BYOK and routing | Retain Addendum B behavior: install an ordinary personal `minimax` key, permit normal BYOK management, and use ordinary MiniMax routing | +| Local end-of-access enforcement | Billing lifecycle cron removes unchanged Kilo-installed BYOK configuration when it processes an expired or canceled subscription; pilot accepts delay until the next scheduled sweep and does not add request-time entitlement enforcement | +| Upstream revocation | MiniMax credential revocation is manual; terminal lifecycle processing immediately moves the originally issued inventory credential to `revocation_pending` | +| Admin operations | Build an in-app admin console for validated inventory upload and manual revocation remediation | +| Credential identification | Support may reveal one raw issued credential for a pending or failed work item because MiniMax manual revocation requires the raw key | +| Reveal safeguards | Raw credential reveal is an explicit admin-only action behind a sensitive-data warning; the pilot does not require Coding Plans reveal/remediation audit-log history | +| Revocation persistence | Inventory row status and disposition fields record `revocation_pending`, `revocation_failed`, or `revoked`; confirmed revocation clears encrypted credential material | +| Operational instructions | Admin console links to an externally maintained controlled support playbook | +| Inventory eligibility | A MiniMax credential must pass approved ordinary MiniMax route/model validation before it becomes `available` inventory | +| Paid extensions | Initial release supports activation and recurring renewal only; it rejects new purchase requests for a live subscription | +| BYOK warning UX | Updating, disabling, or deleting an unchanged Token Plan Plus-installed key requires warning confirmation; testing and re-enabling do not | +| BYOK test failures | Return generic customer errors and log sanitized diagnostics only; do not expose raw provider or SDK error text | +| Customer revocation copy | Customer surfaces state that Kilo revokes its issued MiniMax credential when plan access ends; manual support mechanics remain internal | +| Grace display | `past_due` payment recovery deadline displays local date and time | + +### Already aligned branch work + +No replacement work is required for these areas unless affected by implementation below: + +- `apps/web/src/lib/coding-plans/pricing.ts` uses Token Plan Plus with ordinary provider identity `minimax`. +- `packages/db/src/schema.ts` uses `provider_id`, nullable `installed_byok_key_id`, managed-origin metadata, terminal inventory states, charged terms, and live-subscription uniqueness. +- `apps/web/src/lib/coding-plans/index.ts` implements guarded initial activation, inventory claim, ordinary MiniMax installation, and request idempotency. +- `apps/web/src/routers/byok-router.ts` permits normal BYOK mutation and transfers cleanup ownership when the installed credential value is replaced. +- Existing lifecycle and deletion paths already place an issued inventory key in `revocation_pending`; they require completion and coverage work described below. + +### Work 1: manual revocation admin console + +Add an admin-only Coding Plans operations surface, preferably under `apps/web/src/app/admin/coding-plans/`, backed by `adminProcedure` endpoints in `apps/web/src/routers/coding-plans-router.ts`. + +| Operation | Behavior | +|---|---| +| Inventory summary | Display non-secret counts grouped by `plan_id` and lifecycle status | +| Revocation queue | List individual `revocation_pending` and `revocation_failed` credentials with inventory ID, plan, status, revocation request time, attempt count, and sanitized latest failure; do not return raw or encrypted key material | +| Reveal credential | For one selected pending/failed inventory row only, show a sensitive-data warning, then explicitly retrieve its raw key for authorized support to use in MiniMax admin console | +| Mark revoked | After external confirmation, transition inventory to `revoked`, set `revoked_at`, increment the operation/attempt count as appropriate, clear `last_revocation_error`, and clear `encrypted_api_key` in the same transaction | +| Mark failed | Keep the credential terminal, transition to `revocation_failed`, retain encrypted material needed for later manual retry, increment attempt count, and store a sanitized failure explanation | +| Requeue | Move a failed item back to `revocation_pending` for another manual attempt without restoring local access or availability | +| Upload inventory | Accept new MiniMax credentials, validate them before availability, encrypt accepted keys, fingerprint for duplicate detection, and insert `available` inventory rows | + +Required implementation changes: + +- Replace automated-provider assumptions in `apps/web/src/lib/coding-plans/revocation.ts`; production code must model manual reveal, completion, failure, and requeue transitions rather than calling an injected MiniMax revoker. +- Replace or redefine `adminRetryRevocation`, which currently only resets status, with the manual-remediation operations above. +- Link the admin console to the controlled external support playbook for MiniMax revocation and inventory replenishment. +- Do not add a Coding Plans audit-log table or extend generic audit storage for pilot operations. + +### Work 2: credential upload validation and secret safety + +Update inventory admission so subscribers cannot receive untested MiniMax keys. + +- Validate every candidate key through the approved ordinary MiniMax BYOK route and supported Token Plan Plus model behavior before it can be stored with `status = 'available'`. +- Reuse existing encryption handling and keyed fingerprint duplicate protection after validation succeeds. +- Reject invalid, incompatible, or duplicate keys without exposing their value in responses, logs, analytics, or monitoring. +- Keep raw values out of admin list and summary APIs. Raw disclosure is permitted only through the explicit pending/failed remediation reveal operation. +- Preserve terminal inventory rules: an issued credential never returns to `available`, including after manual revocation failure. + +Primary files: + +- `apps/web/src/lib/coding-plans/index.ts` +- `apps/web/src/lib/coding-plans/revocation.ts` +- `apps/web/src/routers/coding-plans-router.ts` +- New admin console files under `apps/web/src/app/admin/coding-plans/` + +### Work 3: activation and billing API simplification + +Remove hidden prepaid extension behavior from `subscribeToCodingPlan()`. + +- Continue returning the prior committed activation result when `(userId, planId, idempotencyKey)` matches a successfully charged term. +- For any new request while the user already has an `active` or `past_due` subscription for that Plan ID, reject the purchase instead of creating an `extension` term or extending `current_period_end`. +- Retain recurring renewal as the only initial-release path that purchases a later billing period. +- Existing `extension` term type may remain in schema only if harmless for future compatibility; no initial API or UI path may create it. + +Primary files: + +- `apps/web/src/lib/coding-plans/index.ts` +- `apps/web/src/routers/coding-plans-router.ts` +- `apps/web/src/lib/coding-plans/index.test.ts` +- `apps/web/src/routers/coding-plans-router.test.ts` + +### Work 4: cron-driven lifecycle termination + +Keep cron-based local cleanup for the pilot and finish its required behavior. Do not introduce request-time entitlement lookup into ordinary MiniMax BYOK routing. + +| Termination cause | Required local behavior at processing time | Required inventory behavior | +|---|---|---| +| Scheduled user cancellation | At first sweep on or after paid-through date, cancel subscription and delete linked BYOK row only if it is still Kilo-installed | Set originally issued credential to `revocation_pending` | +| Renewal without sufficient credits and no recovery | At due sweep, cancel subscription and delete only unchanged installed row | Set originally issued credential to `revocation_pending` | +| Expired `past_due` recovery | At first sweep after stored grace deadline without successful renewal, cancel subscription and delete only unchanged installed row | Set originally issued credential to `revocation_pending` | +| Administrative termination | Cancel and conditionally delete unchanged installed row immediately when action runs | Set originally issued credential to `revocation_pending` | +| Account deletion | Remove BYOK configuration under user deletion policy and terminalize subscription immediately | Set issued credential to `revocation_pending` and anonymize inventory subscriber link | + +Invariants: + +- A replacement or subsequently created user-owned `minimax` key is never deleted by Coding Plan cancellation, billing failure, or manual revocation work. +- Manual MiniMax revocation does not control local cleanup. Once lifecycle processing terminalizes subscription, Kilo-installed configuration is removed whether upstream work is pending or failed. +- Pilot accepts that unchanged Kilo-installed access may remain usable between paid-period or grace deadline and the next cron execution. + +Primary files: + +- `apps/web/src/lib/coding-plans/billing-lifecycle-cron.ts` +- `apps/web/src/lib/coding-plans/index.ts` +- `apps/web/src/lib/user/index.ts` + +### Work 5: ordinary BYOK safety and warning flows + +Retain normal `/byok` actions for the Kilo-installed ordinary MiniMax key, with confirmation only for changes that may remove paid routing access. + +| User action | UI requirement | Backend result | +|---|---|---| +| Test | No billing warning confirmation | Execute ordinary MiniMax key test; billing and cleanup ownership remain unchanged | +| Re-enable | No billing warning confirmation | Enable ordinary routing; billing and cleanup ownership remain unchanged | +| Disable | Confirm that MiniMax routing stops while Token Plan Plus billing continues until canceled in Subscription Center | Disable key; subscription and cleanup ownership remain unchanged | +| Update credential value | Confirm that replacement changes routing while billing continues | Save new value, set origin to user-managed, and detach `installed_byok_key_id` | +| Delete | Confirm that deletion removes MiniMax routing while billing continues | Delete row and clear association without canceling subscription | + +Also required: + +- Keep a compact provenance indicator for an unchanged installed row, such as `Configured by Token Plan Plus`. +- Sanitize BYOK key-test failures: never log or return unfiltered provider response bodies or SDK exception text. +- Add accessible names to icon-only test, update, delete, and reveal/hide-input controls. + +Primary files: + +- `apps/web/src/routers/byok-router.ts` +- `apps/web/src/components/organizations/byok/BYOKKeysManager.tsx` +- `apps/web/src/routers/byok-router.test.ts` + +### Work 6: Subscription Center updates + +Retain implemented catalog, status, cancellation, and billing-history surfaces, then close pilot UI gaps: + +- Display `past_due` grace expiry with date and local time in summary/detail messaging. +- Keep customer-facing revocation wording at product outcome level: Kilo revokes its issued MiniMax credential when plan access ends. +- Continue stating that Kilo deletes only its unchanged installed MiniMax configuration; a replaced or later user-created key remains untouched. +- Continue prohibiting saved raw-key view or copy controls on customer-facing surfaces. +- Programmatically label the terminal-history toggle and preserve mobile usability of all actions. + +Primary files: + +- `apps/web/src/components/subscriptions/coding-plans/CodingPlanDetail.tsx` +- `apps/web/src/components/subscriptions/coding-plans/CodingPlansGroup.tsx` +- `apps/web/src/components/subscriptions/helpers.ts` +- `apps/web/src/components/subscriptions/TerminalToggle.tsx` + +### Work 7: API contract additions + +Revise Coding Plans admin API to support manual operations. Final endpoint names may follow router conventions, but behavior must cover: + +| Endpoint behavior | Input | Output/side effects | +|---|---|---| +| List remediation work | Optional `planId`, status/page filters | Non-secret pending/failed inventory rows only | +| Reveal selected credential | `{ inventoryKeyId }` | Admin-only raw key response for an eligible pending/failed row after explicit UI confirmation | +| Mark manually revoked | `{ inventoryKeyId }` | Set `revoked`, timestamp completion, clear encrypted secret material | +| Mark manual failure | `{ inventoryKeyId, reason }` | Set `revocation_failed` with sanitized reason and retained retry material | +| Requeue manual revocation | `{ inventoryKeyId }` | Set `revocation_pending` for a failed/pending item without re-enabling access | +| Upload validated inventory | `{ planId, keys }` | Validate keys, then encrypt/dedupe/store accepted available inventory without returning raw material | + +Security rules: + +- Every remediation procedure uses `adminProcedure`. +- Queue, count, and status responses never contain raw or encrypted credential values. +- Explicit reveal returns one raw value only for an eligible terminal remediation state. +- Logs and monitoring never contain issued keys, reveal responses, authorization headers, or unfiltered provider errors. +- Pilot does not require an audit event log for reveal or manual transitions; inventory disposition fields remain required. + +### Work 8: tests and validation + +Replace obsolete automated-revoker and prepaid-extension expectations and add coverage for the pilot contract. + +| Test area | Required coverage | +|---|---| +| Activation | Occupied MiniMax slot, including disabled key, rejects without charge or assignment; validated available credential installs ordinary `minimax` BYOK; idempotency retry returns original result | +| No extension | A new purchase request for an already live subscription is rejected and creates no debit or term; recurring renewal still advances period exactly once | +| Inventory upload | Validation occurs before `available`; invalid/incompatible and duplicate credentials never become assignable; admin responses omit raw/encrypted values | +| Manual revocation admin | Pending/failed list is non-secret; explicit reveal is admin-only and eligibility-checked; mark revoked clears encrypted key; failure/requeue transitions remain terminal | +| Lifecycle cron | User cancellation, unfunded renewal, and expired grace delete only unchanged installed row, preserve replacement/recreated keys, and set original inventory to `revocation_pending` | +| Account deletion | Removes local BYOK access, terminalizes subscription, anonymizes inventory linkage, and creates pending manual revocation work | +| BYOK UX/API | Installed key remains testable/manageable; update/disable/delete warnings render; update transfers ownership; delete/disable do not cancel billing; failed test errors are generic and sanitized | +| Subscription UI | Configured offering, statuses, billing history, revocation messaging, local-time grace deadline, conditional cleanup copy, and absence of customer raw-key controls | +| Accessibility | Icon-only BYOK actions and terminal-history toggle have accessible names; warning/reveal dialogs remain keyboard operable | + +### Implementation order + +| Phase | Work | Primary files | +|---|---|---| +| 1 | Remove prepaid extension behavior and update backend contract tests | `apps/web/src/lib/coding-plans/index.ts`, router/tests | +| 2 | Implement validate-on-upload and manual revocation transition functions | `apps/web/src/lib/coding-plans/`, `apps/web/src/routers/coding-plans-router.ts` | +| 3 | Build admin console for inventory and manual revocation, with external playbook link | `apps/web/src/app/admin/coding-plans/`, admin navigation/components | +| 4 | Complete cron/deletion preservation tests and disposition behavior | Lifecycle and user deletion files/tests | +| 5 | Implement BYOK confirmation, sanitized errors, and accessibility fixes | BYOK router/component/tests | +| 6 | Update Subscription Center time/copy/accessibility behavior | Subscription components/tests | +| 7 | Validate ordinary MiniMax provider/model behavior and load initial available key inventory | Admin console plus approved operational procedure | +| 8 | Run targeted tests, changed-package type checking, and formatting before release | Affected packages and routes | + +### Pilot release acceptance + +- Full pilot implementation is complete before deployment; no temporary purchase feature flag is required. +- Initial available MiniMax credential inventory has been validated and loaded through admin tooling. +- Ordinary MiniMax routing/model behavior for Token Plan Plus credentials has been operationally confirmed. +- Support can use admin console plus external playbook to reveal one pending/failed issued key, revoke it in MiniMax, and record disposition in Kilo. +- Customer and ordinary admin responses do not expose issued credentials; explicit admin remediation reveal is the only permitted raw issued-key disclosure. +- Issued credentials remain terminal after assignment and never return to available inventory. + +### Explicitly accepted pilot limitations + +- Access through an unchanged Kilo-installed MiniMax BYOK key may continue between its paid-period or grace deadline and the next billing lifecycle cron execution. This is accepted for the pilot; manual upstream revocation does not determine local cutoff. +- Admin credential reveal and manual remediation actions are restricted to admin users and protected from ordinary exposure, but the pilot does not require dedicated or generic audit-log history for those actions. From 731faa6536ba7c7d4855c1b17cdb9044204bbc30 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 28 May 2026 14:58:57 +0200 Subject: [PATCH 2/8] feat(coding-plans): implement managed MiniMax lifecycle --- .../api/cron/coding-plans-billing/route.ts | 36 ++ .../byok/coding-plan-entitlement.test.ts | 59 +++ apps/web/src/lib/ai-gateway/byok/index.ts | 22 +- apps/web/src/lib/ai-gateway/byok/types.ts | 5 +- .../billing-lifecycle-cron.test.ts | 228 +++++++++ .../coding-plans/billing-lifecycle-cron.ts | 406 +++++++++++++++ apps/web/src/lib/coding-plans/index.test.ts | 389 +++++++++++++++ apps/web/src/lib/coding-plans/index.ts | 461 ++++++++++++++++++ .../lib/coding-plans/inventory-validation.ts | 42 ++ apps/web/src/lib/coding-plans/pricing.ts | 36 ++ apps/web/src/lib/coding-plans/revocation.ts | 158 ++++++ apps/web/src/lib/constants.ts | 1 - apps/web/src/lib/user/index.test.ts | 92 ++++ apps/web/src/lib/user/index.ts | 58 ++- apps/web/src/routers/byok-router.test.ts | 137 +++++- apps/web/src/routers/byok-router.ts | 108 ++-- .../src/routers/coding-plans-router.test.ts | 269 ++++++++++ apps/web/src/routers/coding-plans-router.ts | 357 ++++++++++++++ apps/web/src/routers/root-router.ts | 2 + apps/web/vercel.json | 4 + .../coding-plans/available-credentials.ts | 156 ++++++ .../coding-plans/occupied-minimax-byok.ts | 105 ++++ packages/db/src/schema-types.ts | 37 ++ packages/db/src/schema.ts | 354 ++++++++++++-- 24 files changed, 3415 insertions(+), 107 deletions(-) create mode 100644 apps/web/src/app/api/cron/coding-plans-billing/route.ts create mode 100644 apps/web/src/lib/ai-gateway/byok/coding-plan-entitlement.test.ts create mode 100644 apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts create mode 100644 apps/web/src/lib/coding-plans/billing-lifecycle-cron.ts create mode 100644 apps/web/src/lib/coding-plans/index.test.ts create mode 100644 apps/web/src/lib/coding-plans/index.ts create mode 100644 apps/web/src/lib/coding-plans/inventory-validation.ts create mode 100644 apps/web/src/lib/coding-plans/pricing.ts create mode 100644 apps/web/src/lib/coding-plans/revocation.ts create mode 100644 apps/web/src/routers/coding-plans-router.test.ts create mode 100644 apps/web/src/routers/coding-plans-router.ts create mode 100644 dev/seed/coding-plans/available-credentials.ts create mode 100644 dev/seed/coding-plans/occupied-minimax-byok.ts diff --git a/apps/web/src/app/api/cron/coding-plans-billing/route.ts b/apps/web/src/app/api/cron/coding-plans-billing/route.ts new file mode 100644 index 0000000000..04b74b9390 --- /dev/null +++ b/apps/web/src/app/api/cron/coding-plans-billing/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/lib/drizzle'; +import { CRON_SECRET } from '@/lib/config.server'; +import { sentryLogger } from '@/lib/utils.server'; +import { runCodingPlanBillingLifecycleCron } from '@/lib/coding-plans/billing-lifecycle-cron'; + +if (!CRON_SECRET) { + throw new Error('CRON_SECRET is not configured in environment variables'); +} + +export async function GET(request: Request) { + const authHeader = request.headers.get('authorization'); + const expectedAuth = `Bearer ${CRON_SECRET}`; + if (authHeader !== expectedAuth) { + sentryLogger( + 'cron', + 'warning' + )( + 'SECURITY: Invalid coding-plans-billing CRON authorization attempt: ' + + (authHeader ? 'Invalid authorization header' : 'Missing authorization header') + ); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const summary = await runCodingPlanBillingLifecycleCron(db); + + return NextResponse.json( + { + success: true, + summary, + timestamp: new Date().toISOString(), + }, + { status: 200 } + ); +} diff --git a/apps/web/src/lib/ai-gateway/byok/coding-plan-entitlement.test.ts b/apps/web/src/lib/ai-gateway/byok/coding-plan-entitlement.test.ts new file mode 100644 index 0000000000..f5335be484 --- /dev/null +++ b/apps/web/src/lib/ai-gateway/byok/coding-plan-entitlement.test.ts @@ -0,0 +1,59 @@ +/* eslint-disable drizzle/enforce-delete-with-where */ +import { encryptApiKey } from '@/lib/ai-gateway/byok/encryption'; +import { getBYOKforUser } from '@/lib/ai-gateway/byok'; +import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; +import { db } from '@/lib/drizzle'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { byok_api_keys, kilocode_users } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; + +async function seedMiniMaxKey(managementSource: 'user' | 'coding_plan', isEnabled = true) { + const user = await insertTestUser(); + await db.insert(byok_api_keys).values({ + kilo_user_id: user.id, + provider_id: 'minimax', + encrypted_api_key: encryptApiKey(`minimax-${crypto.randomUUID()}`, BYOK_ENCRYPTION_KEY), + management_source: managementSource, + is_enabled: isEnabled, + created_by: user.id, + }); + return user; +} + +afterEach(async () => { + await db.delete(byok_api_keys); + await db.delete(kilocode_users); +}); + +describe('Coding Plan MiniMax BYOK routing', () => { + it('loads a Token Plan Plus-installed key through ordinary MiniMax routing', async () => { + const user = await seedMiniMaxKey('coding_plan'); + + const byok = await getBYOKforUser(db, user.id, ['minimax']); + + expect(byok).toHaveLength(1); + expect(byok?.[0].providerId).toBe('minimax'); + }); + + it('uses a subscriber replacement as ordinary MiniMax BYOK', async () => { + const user = await seedMiniMaxKey('user'); + + const byok = await getBYOKforUser(db, user.id, ['minimax']); + + expect(byok).toHaveLength(1); + expect(byok?.[0].providerId).toBe('minimax'); + }); + + it('does not route a disabled MiniMax BYOK key', async () => { + const user = await seedMiniMaxKey('coding_plan', false); + + expect(await getBYOKforUser(db, user.id, ['minimax'])).toBeNull(); + }); + + it('does not route after the configured MiniMax key is deleted', async () => { + const user = await seedMiniMaxKey('coding_plan'); + await db.delete(byok_api_keys).where(eq(byok_api_keys.kilo_user_id, user.id)); + + expect(await getBYOKforUser(db, user.id, ['minimax'])).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/ai-gateway/byok/index.ts b/apps/web/src/lib/ai-gateway/byok/index.ts index c24c5d3a16..5b9905346e 100644 --- a/apps/web/src/lib/ai-gateway/byok/index.ts +++ b/apps/web/src/lib/ai-gateway/byok/index.ts @@ -1,6 +1,6 @@ import { type db } from '@/lib/drizzle'; import { byok_api_keys } from '@kilocode/db/schema'; -import { eq, and, inArray } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import type { EncryptedData } from '@/lib/ai-gateway/byok/encryption'; import { decryptApiKey } from '@/lib/ai-gateway/byok/encryption'; import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; @@ -19,7 +19,7 @@ export async function getModelUserByokProviders(modelId: string): Promise { + if (providerIds.length === 0) { + return null; + } const rows = await fromDb .select({ encrypted_api_key: byok_api_keys.encrypted_api_key, @@ -68,11 +71,7 @@ export async function getBYOKforUser( ) .orderBy(byok_api_keys.created_at); - if (rows.length === 0) { - return null; - } - - return rows.map(row => decryptByokRow(row)); + return rows.length === 0 ? null : rows.map(row => decryptByokRow(row)); } export async function getBYOKforOrganization( @@ -80,6 +79,9 @@ export async function getBYOKforOrganization( organizationId: string, providerIds: UserByokProviderId[] ): Promise { + if (providerIds.length === 0) { + return null; + } const rows = await fromDb .select({ encrypted_api_key: byok_api_keys.encrypted_api_key, @@ -95,9 +97,5 @@ export async function getBYOKforOrganization( ) .orderBy(byok_api_keys.created_at); - if (rows.length === 0) { - return null; - } - - return rows.map(row => decryptByokRow(row)); + return rows.length === 0 ? null : rows.map(row => decryptByokRow(row)); } diff --git a/apps/web/src/lib/ai-gateway/byok/types.ts b/apps/web/src/lib/ai-gateway/byok/types.ts index 4bd577b7c3..f990707497 100644 --- a/apps/web/src/lib/ai-gateway/byok/types.ts +++ b/apps/web/src/lib/ai-gateway/byok/types.ts @@ -1,3 +1,4 @@ +import { UserByokProviderIdSchema } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; import * as z from 'zod'; // API response type (never includes decrypted key) @@ -5,6 +6,7 @@ export type BYOKApiKeyResponse = { id: string; provider_id: string; provider_name: string; + management_source: 'user' | 'coding_plan'; is_enabled: boolean; created_at: string; updated_at: string; @@ -20,7 +22,7 @@ const OptionalOrganizationIdSchema = z.object({ // Note: organizationId is optional - if provided, enforces org owner/billing access // If not provided, uses the authenticated user's kilo_user_id export const CreateBYOKKeyInputSchema = OptionalOrganizationIdSchema.extend({ - provider_id: z.string().min(1), + provider_id: UserByokProviderIdSchema, api_key: z.string().min(1), }); @@ -49,6 +51,7 @@ export const BYOKApiKeyResponseSchema = z.object({ id: z.string().uuid(), provider_id: z.string(), provider_name: z.string(), + management_source: z.enum(['user', 'coding_plan']), is_enabled: z.boolean(), created_at: z.string(), updated_at: z.string(), diff --git a/apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts b/apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts new file mode 100644 index 0000000000..7c91a93f61 --- /dev/null +++ b/apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts @@ -0,0 +1,228 @@ +/* eslint-disable drizzle/enforce-delete-with-where */ +import { eq } from 'drizzle-orm'; + +import { runCodingPlanBillingLifecycleCron } from '@/lib/coding-plans/billing-lifecycle-cron'; +import { subscribeToCodingPlan, uploadKeysToInventory } from '@/lib/coding-plans'; +import { db } from '@/lib/drizzle'; +import { maybePerformAutoTopUp } from '@/lib/autoTopUp'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + byok_api_keys, + coding_plan_key_inventory, + coding_plan_subscriptions, + coding_plan_terms, + credit_transactions, + kilocode_users, +} from '@kilocode/db/schema'; + +jest.mock('@/lib/autoTopUp', () => ({ + maybePerformAutoTopUp: jest.fn(async () => undefined), +})); + +const PLAN_ID = 'minimax-token-plan-plus'; +const COST_MICRODOLLARS = 20_000_000; +const dueAt = new Date(Date.now() - 60_000).toISOString(); + +async function createSubscription(balance = COST_MICRODOLLARS, autoTopUpEnabled = false) { + const user = await insertTestUser({ + total_microdollars_acquired: balance, + microdollars_used: 0, + auto_top_up_enabled: autoTopUpEnabled, + }); + await uploadKeysToInventory(PLAN_ID, [`cron-key-${crypto.randomUUID()}`], { + validateCredential: async () => true, + }); + const created = await subscribeToCodingPlan(user.id, PLAN_ID, `activate-${crypto.randomUUID()}`); + await db + .update(coding_plan_subscriptions) + .set({ current_period_end: dueAt, credit_renewal_at: dueAt }) + .where(eq(coding_plan_subscriptions.id, created.subscriptionId)); + return { user, subscriptionId: created.subscriptionId }; +} + +afterEach(async () => { + jest.mocked(maybePerformAutoTopUp).mockClear(); + await db.delete(coding_plan_terms); + await db.delete(coding_plan_subscriptions); + await db.delete(byok_api_keys); + await db.delete(coding_plan_key_inventory); + await db.delete(credit_transactions); + await db.delete(kilocode_users); +}); + +describe('Coding Plan billing lifecycle cron', () => { + it('renews atomically with a charged term and retains assigned access', async () => { + const { subscriptionId } = await createSubscription(COST_MICRODOLLARS * 2); + + const summary = await runCodingPlanBillingLifecycleCron(db); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + const terms = await db + .select() + .from(coding_plan_terms) + .where(eq(coding_plan_terms.subscription_id, subscriptionId)); + const [credential] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); + const renewalTransaction = await db + .select({ description: credit_transactions.description }) + .from(credit_transactions) + .where(eq(credit_transactions.description, 'Coding plan renewal: MiniMax Token Plan Plus')); + + expect(summary.renewals).toBe(1); + expect(subscription.status).toBe('active'); + expect(terms.map(term => term.kind)).toEqual(['activation', 'renewal']); + expect(renewalTransaction).toEqual([ + { description: 'Coding plan renewal: MiniMax Token Plan Plus' }, + ]); + expect(credential.status).toBe('assigned'); + }); + + it('renews after the subscriber deletes the installed MiniMax BYOK key', async () => { + const { subscriptionId } = await createSubscription(COST_MICRODOLLARS * 2); + const [before] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + await db.delete(byok_api_keys).where(eq(byok_api_keys.id, before.installed_byok_key_id!)); + + const summary = await runCodingPlanBillingLifecycleCron(db); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + + expect(summary.renewals).toBe(1); + expect(subscription.status).toBe('active'); + expect(subscription.installed_byok_key_id).toBeNull(); + }); + + it('ends user-canceled access at period end and queues credential revocation', async () => { + const { subscriptionId } = await createSubscription(COST_MICRODOLLARS); + await db + .update(coding_plan_subscriptions) + .set({ cancel_at_period_end: true }) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + + const summary = await runCodingPlanBillingLifecycleCron(db); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + const [credential] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); + + expect(summary.canceled_at_period_end).toBe(1); + expect(subscription.status).toBe('canceled'); + expect(subscription.cancellation_reason).toBe('user_canceled'); + expect(subscription.installed_byok_key_id).toBeNull(); + expect(credential.status).toBe('revocation_pending'); + expect(maybePerformAutoTopUp).not.toHaveBeenCalled(); + }); + + it('terminates unfunded renewal immediately when auto-top-up is disabled', async () => { + const { subscriptionId } = await createSubscription(); + + const summary = await runCodingPlanBillingLifecycleCron(db); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + const [credential] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); + + expect(summary.canceled_insufficient_balance).toBe(1); + expect(subscription.status).toBe('canceled'); + expect(subscription.cancellation_reason).toBe('insufficient_credits'); + expect(credential.status).toBe('revocation_pending'); + }); + + it('allows one past-due grace period and one auto-top-up attempt', async () => { + const { subscriptionId } = await createSubscription(COST_MICRODOLLARS, true); + + const firstSummary = await runCodingPlanBillingLifecycleCron(db); + await runCodingPlanBillingLifecycleCron(db); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + + expect(firstSummary.past_due_started).toBe(1); + expect(subscription.status).toBe('past_due'); + expect(subscription.payment_grace_expires_at).not.toBeNull(); + expect(maybePerformAutoTopUp).toHaveBeenCalledTimes(1); + }); + + it('restores active status when credits arrive during grace', async () => { + const { user, subscriptionId } = await createSubscription(COST_MICRODOLLARS, true); + await runCodingPlanBillingLifecycleCron(db); + await db + .update(kilocode_users) + .set({ total_microdollars_acquired: COST_MICRODOLLARS * 2 }) + .where(eq(kilocode_users.id, user.id)); + + await runCodingPlanBillingLifecycleCron(db); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + + expect(subscription.status).toBe('active'); + expect(subscription.payment_grace_expires_at).toBeNull(); + }); + + it('terminates access when payment grace expires without funding', async () => { + const { subscriptionId } = await createSubscription(COST_MICRODOLLARS, true); + await runCodingPlanBillingLifecycleCron(db); + await db + .update(coding_plan_subscriptions) + .set({ payment_grace_expires_at: dueAt }) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + + await runCodingPlanBillingLifecycleCron(db); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + const [credential] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); + + expect(subscription.status).toBe('canceled'); + expect(subscription.cancellation_reason).toBe('insufficient_credits'); + expect(credential.status).toBe('revocation_pending'); + }); + + it('preserves a replacement MiniMax key when scheduled cancellation is processed', async () => { + const { subscriptionId, user } = await createSubscription(COST_MICRODOLLARS); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + await db + .update(byok_api_keys) + .set({ management_source: 'user' }) + .where(eq(byok_api_keys.id, subscription.installed_byok_key_id!)); + await db + .update(coding_plan_subscriptions) + .set({ cancel_at_period_end: true, installed_byok_key_id: null }) + .where(eq(coding_plan_subscriptions.id, subscriptionId)); + + await runCodingPlanBillingLifecycleCron(db); + const remainingKeys = await db + .select() + .from(byok_api_keys) + .where(eq(byok_api_keys.kilo_user_id, user.id)); + + expect(remainingKeys).toHaveLength(1); + expect(remainingKeys[0].management_source).toBe('user'); + }); +}); diff --git a/apps/web/src/lib/coding-plans/billing-lifecycle-cron.ts b/apps/web/src/lib/coding-plans/billing-lifecycle-cron.ts new file mode 100644 index 0000000000..68064aad0a --- /dev/null +++ b/apps/web/src/lib/coding-plans/billing-lifecycle-cron.ts @@ -0,0 +1,406 @@ +import 'server-only'; + +import { addDays, addHours } from 'date-fns'; +import { and, eq, inArray, lte, sql } from 'drizzle-orm'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; + +import { maybePerformAutoTopUp } from '@/lib/autoTopUp'; +import { getCodingPlanPrice } from '@/lib/coding-plans/pricing'; +import { maybeIssueKiloPassBonusFromUsageThreshold } from '@/lib/kilo-pass/usage-triggered-bonus'; +import { sentryLogger } from '@/lib/utils.server'; +import { + byok_api_keys, + coding_plan_key_inventory, + coding_plan_subscriptions, + coding_plan_terms, + credit_transactions, + kilocode_users, +} from '@kilocode/db/schema'; +import type * as schema from '@kilocode/db/schema'; + +const logInfo = sentryLogger('coding-plans-billing-cron', 'info'); +const logError = sentryLogger('coding-plans-billing-cron', 'error'); + +export type CodingPlanCronSummary = { + renewals: number; + renewals_skipped_duplicate: number; + canceled_at_period_end: number; + canceled_insufficient_balance: number; + past_due_started: number; + auto_top_up_triggered: number; + errors: number; +}; + +type RenewalRow = { + id: string; + user_id: string; + installed_byok_key_id: string | null; + key_inventory_id: string | null; + plan_id: string; + status: 'active' | 'past_due'; + cost_microdollars: number; + billing_period_days: number; + current_period_end: string; + credit_renewal_at: string; + cancel_at_period_end: boolean; + payment_grace_expires_at: string | null; + total_microdollars_acquired: number; + microdollars_used: number; + auto_top_up_enabled: boolean; + next_credit_expiration_at: string | null; + user_updated_at: string; +}; + +type RenewalResult = 'renewed' | 'duplicate' | 'past_due_started' | 'waiting' | 'terminated'; + +function emptySummary(): CodingPlanCronSummary { + return { + renewals: 0, + renewals_skipped_duplicate: 0, + canceled_at_period_end: 0, + canceled_insufficient_balance: 0, + past_due_started: 0, + auto_top_up_triggered: 0, + errors: 0, + }; +} + +export async function runCodingPlanBillingLifecycleCron( + database: PostgresJsDatabase +): Promise { + const summary = emptySummary(); + const nowIso = new Date().toISOString(); + + try { + await sweepCancelAtPeriodEnd(database, nowIso, summary); + } catch (error) { + summary.errors++; + logError('Cancel-at-period-end sweep failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + + try { + await sweepRenewals(database, nowIso, summary); + } catch (error) { + summary.errors++; + logError('Renewal sweep failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + + logInfo('Coding plan billing cron completed', summary); + return summary; +} + +async function sweepCancelAtPeriodEnd( + database: PostgresJsDatabase, + nowIso: string, + summary: CodingPlanCronSummary +): Promise { + const rows = await database + .select({ + id: coding_plan_subscriptions.id, + installed_byok_key_id: coding_plan_subscriptions.installed_byok_key_id, + key_inventory_id: coding_plan_subscriptions.key_inventory_id, + }) + .from(coding_plan_subscriptions) + .where( + and( + eq(coding_plan_subscriptions.status, 'active'), + eq(coding_plan_subscriptions.cancel_at_period_end, true), + lte(coding_plan_subscriptions.current_period_end, nowIso) + ) + ); + + for (const row of rows) { + try { + await database.transaction(async tx => { + await tx + .update(coding_plan_subscriptions) + .set({ + status: 'canceled', + canceled_at: nowIso, + cancellation_reason: 'user_canceled', + cancel_at_period_end: false, + installed_byok_key_id: null, + }) + .where( + and( + eq(coding_plan_subscriptions.id, row.id), + eq(coding_plan_subscriptions.status, 'active') + ) + ); + if (row.installed_byok_key_id) { + await tx + .delete(byok_api_keys) + .where( + and( + eq(byok_api_keys.id, row.installed_byok_key_id), + eq(byok_api_keys.management_source, 'coding_plan') + ) + ); + } + if (row.key_inventory_id) { + await tx + .update(coding_plan_key_inventory) + .set({ status: 'revocation_pending', revocation_requested_at: nowIso }) + .where(eq(coding_plan_key_inventory.id, row.key_inventory_id)); + } + }); + summary.canceled_at_period_end++; + } catch (error) { + summary.errors++; + logError('Failed to end canceled coding plan access', { + subscriptionId: row.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +async function sweepRenewals( + database: PostgresJsDatabase, + nowIso: string, + summary: CodingPlanCronSummary +): Promise { + const rows = await database + .select({ + id: coding_plan_subscriptions.id, + user_id: coding_plan_subscriptions.user_id, + installed_byok_key_id: coding_plan_subscriptions.installed_byok_key_id, + key_inventory_id: coding_plan_subscriptions.key_inventory_id, + plan_id: coding_plan_subscriptions.plan_id, + status: coding_plan_subscriptions.status, + cost_microdollars: coding_plan_subscriptions.cost_microdollars, + billing_period_days: coding_plan_subscriptions.billing_period_days, + current_period_end: coding_plan_subscriptions.current_period_end, + credit_renewal_at: coding_plan_subscriptions.credit_renewal_at, + cancel_at_period_end: coding_plan_subscriptions.cancel_at_period_end, + payment_grace_expires_at: coding_plan_subscriptions.payment_grace_expires_at, + total_microdollars_acquired: kilocode_users.total_microdollars_acquired, + microdollars_used: kilocode_users.microdollars_used, + auto_top_up_enabled: kilocode_users.auto_top_up_enabled, + next_credit_expiration_at: kilocode_users.next_credit_expiration_at, + user_updated_at: kilocode_users.updated_at, + }) + .from(coding_plan_subscriptions) + .innerJoin(kilocode_users, eq(coding_plan_subscriptions.user_id, kilocode_users.id)) + .where( + and( + inArray(coding_plan_subscriptions.status, ['active', 'past_due']), + eq(coding_plan_subscriptions.cancel_at_period_end, false), + lte(coding_plan_subscriptions.credit_renewal_at, nowIso) + ) + ); + + for (const selectedRow of rows) { + const row: RenewalRow = { + ...selectedRow, + status: selectedRow.status === 'past_due' ? 'past_due' : 'active', + }; + try { + const result = await processRenewal(database, row, nowIso); + if (result === 'renewed') { + summary.renewals++; + try { + await maybeIssueKiloPassBonusFromUsageThreshold({ + kiloUserId: row.user_id, + nowIso, + }); + } catch (error) { + logError('Kilo Pass bonus evaluation failed after coding plan renewal', { + user_id: row.user_id, + error: error instanceof Error ? error.message : String(error), + }); + } + } else if (result === 'duplicate') { + summary.renewals_skipped_duplicate++; + } else if (result === 'past_due_started') { + summary.past_due_started++; + try { + await maybePerformAutoTopUp({ + id: row.user_id, + total_microdollars_acquired: row.total_microdollars_acquired, + microdollars_used: row.microdollars_used, + auto_top_up_enabled: row.auto_top_up_enabled, + next_credit_expiration_at: row.next_credit_expiration_at, + updated_at: row.user_updated_at, + }); + } catch (error) { + logError('Auto top-up attempt failed during coding plan recovery', { + user_id: row.user_id, + error: error instanceof Error ? error.message : String(error), + }); + } + summary.auto_top_up_triggered++; + } else if (result === 'terminated') { + summary.canceled_insufficient_balance++; + } + } catch (error) { + summary.errors++; + logError('Failed to process coding plan renewal', { + subscriptionId: row.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +async function processRenewal( + database: PostgresJsDatabase, + selectedRow: RenewalRow, + nowIso: string +): Promise { + return database.transaction(async tx => { + await tx.execute( + sql`SELECT id FROM coding_plan_subscriptions WHERE id = ${selectedRow.id} FOR UPDATE` + ); + await tx.execute( + sql`SELECT id FROM kilocode_users WHERE id = ${selectedRow.user_id} FOR UPDATE` + ); + const [row] = await tx + .select({ + id: coding_plan_subscriptions.id, + user_id: coding_plan_subscriptions.user_id, + installed_byok_key_id: coding_plan_subscriptions.installed_byok_key_id, + key_inventory_id: coding_plan_subscriptions.key_inventory_id, + plan_id: coding_plan_subscriptions.plan_id, + status: coding_plan_subscriptions.status, + cost_microdollars: coding_plan_subscriptions.cost_microdollars, + billing_period_days: coding_plan_subscriptions.billing_period_days, + credit_renewal_at: coding_plan_subscriptions.credit_renewal_at, + cancel_at_period_end: coding_plan_subscriptions.cancel_at_period_end, + payment_grace_expires_at: coding_plan_subscriptions.payment_grace_expires_at, + microdollars_used: kilocode_users.microdollars_used, + auto_top_up_enabled: kilocode_users.auto_top_up_enabled, + }) + .from(coding_plan_subscriptions) + .innerJoin(kilocode_users, eq(coding_plan_subscriptions.user_id, kilocode_users.id)) + .where(eq(coding_plan_subscriptions.id, selectedRow.id)) + .limit(1); + if (!row || row.status === 'canceled' || row.cancel_at_period_end) { + return 'waiting'; + } + + const renewalKey = `renewal:${row.id}:${row.credit_renewal_at}`; + const [existingTerm] = await tx + .select({ id: coding_plan_terms.id }) + .from(coding_plan_terms) + .where( + and( + eq(coding_plan_terms.user_id, row.user_id), + eq(coding_plan_terms.plan_id, row.plan_id), + eq(coding_plan_terms.idempotency_key, renewalKey) + ) + ) + .limit(1); + if (existingTerm) { + return 'duplicate'; + } + + const { rows: chargedUsers } = await tx.execute(sql` + UPDATE kilocode_users + SET microdollars_used = microdollars_used + ${row.cost_microdollars} + WHERE id = ${row.user_id} + AND total_microdollars_acquired - microdollars_used >= ${row.cost_microdollars} + RETURNING id + `); + if (chargedUsers.length > 0) { + const newPeriodEnd = addDays( + new Date(row.credit_renewal_at), + row.billing_period_days + ).toISOString(); + const transactionId = crypto.randomUUID(); + const plan = getCodingPlanPrice(row.plan_id); + const renewalDescription = plan + ? `Coding plan renewal: ${plan.providerName} ${plan.name}` + : 'Coding plan renewal'; + await tx.insert(credit_transactions).values({ + id: transactionId, + kilo_user_id: row.user_id, + amount_microdollars: -row.cost_microdollars, + is_free: false, + description: renewalDescription, + credit_category: `coding-plan:${renewalKey}`, + check_category_uniqueness: true, + original_baseline_microdollars_used: row.microdollars_used, + }); + await tx.insert(coding_plan_terms).values({ + subscription_id: row.id, + user_id: row.user_id, + plan_id: row.plan_id, + kind: 'renewal', + idempotency_key: renewalKey, + period_start: row.credit_renewal_at, + period_end: newPeriodEnd, + cost_microdollars: row.cost_microdollars, + credit_transaction_id: transactionId, + }); + await tx + .update(coding_plan_subscriptions) + .set({ + status: 'active', + current_period_start: row.credit_renewal_at, + current_period_end: newPeriodEnd, + credit_renewal_at: newPeriodEnd, + past_due_started_at: null, + payment_grace_expires_at: null, + auto_top_up_attempted_for_due: null, + }) + .where(eq(coding_plan_subscriptions.id, row.id)); + return 'renewed'; + } + + if (row.status === 'active' && row.auto_top_up_enabled) { + await tx + .update(coding_plan_subscriptions) + .set({ + status: 'past_due', + past_due_started_at: nowIso, + payment_grace_expires_at: addHours(new Date(row.credit_renewal_at), 24).toISOString(), + auto_top_up_attempted_for_due: row.credit_renewal_at, + }) + .where(eq(coding_plan_subscriptions.id, row.id)); + return 'past_due_started'; + } + + if ( + row.status === 'past_due' && + row.payment_grace_expires_at && + new Date(row.payment_grace_expires_at) > new Date(nowIso) + ) { + return 'waiting'; + } + + await tx + .update(coding_plan_subscriptions) + .set({ + status: 'canceled', + canceled_at: nowIso, + cancellation_reason: 'insufficient_credits', + installed_byok_key_id: null, + past_due_started_at: null, + payment_grace_expires_at: null, + auto_top_up_attempted_for_due: null, + }) + .where(eq(coding_plan_subscriptions.id, row.id)); + if (row.installed_byok_key_id) { + await tx + .delete(byok_api_keys) + .where( + and( + eq(byok_api_keys.id, row.installed_byok_key_id), + eq(byok_api_keys.management_source, 'coding_plan') + ) + ); + } + if (row.key_inventory_id) { + await tx + .update(coding_plan_key_inventory) + .set({ status: 'revocation_pending', revocation_requested_at: nowIso }) + .where(eq(coding_plan_key_inventory.id, row.key_inventory_id)); + } + return 'terminated'; + }); +} diff --git a/apps/web/src/lib/coding-plans/index.test.ts b/apps/web/src/lib/coding-plans/index.test.ts new file mode 100644 index 0000000000..85d6e9c13d --- /dev/null +++ b/apps/web/src/lib/coding-plans/index.test.ts @@ -0,0 +1,389 @@ +/* eslint-disable drizzle/enforce-delete-with-where */ +import { eq } from 'drizzle-orm'; + +import { encryptApiKey } from '@/lib/ai-gateway/byok/encryption'; +import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; +import { + cancelCodingPlanSubscription, + getKeyInventoryCounts, + subscribeToCodingPlan, + terminateCodingPlanImmediately, + uploadKeysToInventory, +} from '@/lib/coding-plans'; +import { CODING_PLAN_CATALOG } from '@/lib/coding-plans/pricing'; +import { + markCredentialManuallyRevoked, + markCredentialManualRevocationFailed, + requeueManualCredentialRevocation, + revealCredentialForManualRevocation, +} from '@/lib/coding-plans/revocation'; +import { db } from '@/lib/drizzle'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + byok_api_keys, + coding_plan_key_inventory, + coding_plan_subscriptions, + coding_plan_terms, + credit_transactions, + kilocode_users, +} from '@kilocode/db/schema'; + +const PLAN_ID = 'minimax-token-plan-plus'; +const PROVIDER_ID = 'minimax'; +const COST_MICRODOLLARS = 20_000_000; + +const validatedInventoryUpload = { validateCredential: async () => true }; + +async function seedInventoryKey(key = `managed-test-key-${crypto.randomUUID()}`) { + await uploadKeysToInventory(PLAN_ID, [key], validatedInventoryUpload); +} + +async function createUserWithBalance(microdollars: number) { + return insertTestUser({ + total_microdollars_acquired: microdollars, + microdollars_used: 0, + }); +} + +afterEach(async () => { + await db.delete(coding_plan_terms); + await db.delete(coding_plan_subscriptions); + await db.delete(byok_api_keys); + await db.delete(coding_plan_key_inventory); + await db.delete(credit_transactions); + await db.delete(kilocode_users); +}); + +describe('coding plans', () => { + it('publishes the code-owned MiniMax Token Plan Plus catalog entry', () => { + expect(CODING_PLAN_CATALOG[PLAN_ID]).toEqual({ + planId: PLAN_ID, + providerName: 'MiniMax', + name: 'Token Plan Plus', + providerId: PROVIDER_ID, + costMicrodollars: COST_MICRODOLLARS, + billingPeriodDays: 30, + }); + }); + + it('activates an episode with one charged term and managed BYOK entry', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS); + await seedInventoryKey(); + + const result = await subscribeToCodingPlan(user.id, PLAN_ID, 'activation-request'); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, result.subscriptionId)); + const terms = await db + .select() + .from(coding_plan_terms) + .where(eq(coding_plan_terms.subscription_id, result.subscriptionId)); + const [managedKey] = await db + .select() + .from(byok_api_keys) + .where(eq(byok_api_keys.id, subscription.installed_byok_key_id!)); + const [inventoryKey] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); + + expect(subscription.plan_id).toBe(PLAN_ID); + expect(subscription.provider_id).toBe(PROVIDER_ID); + expect(subscription.status).toBe('active'); + expect(terms).toHaveLength(1); + expect(terms[0].kind).toBe('activation'); + expect(terms[0].cost_microdollars).toBe(COST_MICRODOLLARS); + expect(managedKey.provider_id).toBe(PROVIDER_ID); + expect(managedKey.management_source).toBe('coding_plan'); + expect(inventoryKey.status).toBe('assigned'); + expect(inventoryKey.assigned_to_user_id).toBe(user.id); + }); + + it('returns prior outcome when an activation request is retried', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS * 2); + await seedInventoryKey(); + await seedInventoryKey(); + + const first = await subscribeToCodingPlan(user.id, PLAN_ID, 'same-request'); + const retried = await subscribeToCodingPlan(user.id, PLAN_ID, 'same-request'); + const terms = await db.select().from(coding_plan_terms); + const assigned = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.status, 'assigned')); + const [updatedUser] = await db + .select() + .from(kilocode_users) + .where(eq(kilocode_users.id, user.id)); + + expect(retried.subscriptionId).toBe(first.subscriptionId); + expect(terms).toHaveLength(1); + expect(assigned).toHaveLength(1); + expect(updatedUser.microdollars_used).toBe(COST_MICRODOLLARS); + }); + + it('rejects a new purchase while an active subscription exists', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS * 2); + await seedInventoryKey(); + const activation = await subscribeToCodingPlan(user.id, PLAN_ID, 'activate'); + const [before] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, activation.subscriptionId)); + + await expect(subscribeToCodingPlan(user.id, PLAN_ID, 'second-purchase')).rejects.toThrow( + 'already has a live subscription' + ); + const [after] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, activation.subscriptionId)); + const terms = await db + .select() + .from(coding_plan_terms) + .where(eq(coding_plan_terms.subscription_id, activation.subscriptionId)); + const [updatedUser] = await db + .select() + .from(kilocode_users) + .where(eq(kilocode_users.id, user.id)); + + expect(after.current_period_end).toBe(before.current_period_end); + expect(terms.map(term => term.kind)).toEqual(['activation']); + expect(updatedUser.microdollars_used).toBe(COST_MICRODOLLARS); + }); + + it('cannot overspend credits during concurrent purchase requests', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS); + await seedInventoryKey(); + await seedInventoryKey(); + + const outcomes = await Promise.allSettled([ + subscribeToCodingPlan(user.id, PLAN_ID, 'concurrent-one'), + subscribeToCodingPlan(user.id, PLAN_ID, 'concurrent-two'), + ]); + const [updatedUser] = await db + .select() + .from(kilocode_users) + .where(eq(kilocode_users.id, user.id)); + const subscriptions = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.user_id, user.id)); + + expect(outcomes.filter(result => result.status === 'fulfilled')).toHaveLength(1); + expect(updatedUser.microdollars_used).toBe(COST_MICRODOLLARS); + expect(subscriptions).toHaveLength(1); + }); + + it('rolls back activation when credits are insufficient or capacity is absent', async () => { + const poorUser = await createUserWithBalance(COST_MICRODOLLARS - 1); + await seedInventoryKey(); + await expect(subscribeToCodingPlan(poorUser.id, PLAN_ID, 'poor')).rejects.toThrow( + 'Insufficient credit balance' + ); + + const fundedUser = await createUserWithBalance(COST_MICRODOLLARS); + const [availableKey] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.status, 'available')); + await db + .update(coding_plan_key_inventory) + .set({ status: 'assigned' }) + .where(eq(coding_plan_key_inventory.id, availableKey.id)); + await expect(subscribeToCodingPlan(fundedUser.id, PLAN_ID, 'capacity')).rejects.toThrow( + 'No managed credential' + ); + const [unchargedUser] = await db + .select() + .from(kilocode_users) + .where(eq(kilocode_users.id, fundedUser.id)); + expect(unchargedUser.microdollars_used).toBe(0); + }); + + it('rejects activation when the personal MiniMax BYOK slot is occupied', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS); + await seedInventoryKey(); + await db.insert(byok_api_keys).values({ + kilo_user_id: user.id, + provider_id: PROVIDER_ID, + encrypted_api_key: encryptApiKey('existing-minimax', BYOK_ENCRYPTION_KEY), + is_enabled: false, + created_by: user.id, + }); + + await expect(subscribeToCodingPlan(user.id, PLAN_ID, 'occupied-slot')).rejects.toThrow( + 'Remove your existing MiniMax BYOK key' + ); + const [updatedUser] = await db + .select() + .from(kilocode_users) + .where(eq(kilocode_users.id, user.id)); + const terms = await db.select().from(coding_plan_terms); + const subscriptions = await db.select().from(coding_plan_subscriptions); + const [inventory] = await db.select().from(coding_plan_key_inventory); + + expect(updatedUser.microdollars_used).toBe(0); + expect(terms).toHaveLength(0); + expect(subscriptions).toHaveLength(0); + expect(inventory.status).toBe('available'); + }); + + it('creates a new episode and new credential after immediate termination', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS * 2); + await seedInventoryKey(); + await seedInventoryKey(); + const first = await subscribeToCodingPlan(user.id, PLAN_ID, 'first-episode'); + const [firstSubscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, first.subscriptionId)); + + await terminateCodingPlanImmediately(first.subscriptionId); + const second = await subscribeToCodingPlan(user.id, PLAN_ID, 'second-episode'); + const [terminatedCredential] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, firstSubscription.key_inventory_id!)); + + expect(second.subscriptionId).not.toBe(first.subscriptionId); + expect(terminatedCredential.status).toBe('revocation_pending'); + const subscriptions = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.user_id, user.id)); + expect(subscriptions.map(subscription => subscription.status).sort()).toEqual([ + 'active', + 'canceled', + ]); + }); + + it('preserves a detached user-managed MiniMax key on termination', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS); + await seedInventoryKey(); + const activation = await subscribeToCodingPlan(user.id, PLAN_ID, 'replace-before-end'); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, activation.subscriptionId)); + await db + .update(byok_api_keys) + .set({ management_source: 'user' }) + .where(eq(byok_api_keys.id, subscription.installed_byok_key_id!)); + await db + .update(coding_plan_subscriptions) + .set({ installed_byok_key_id: null }) + .where(eq(coding_plan_subscriptions.id, activation.subscriptionId)); + + await terminateCodingPlanImmediately(activation.subscriptionId); + const remainingKeys = await db + .select() + .from(byok_api_keys) + .where(eq(byok_api_keys.kilo_user_id, user.id)); + + expect(remainingKeys).toHaveLength(1); + expect(remainingKeys[0].management_source).toBe('user'); + }); + + it('schedules user cancellation without immediately removing installed access', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS); + await seedInventoryKey(); + const result = await subscribeToCodingPlan(user.id, PLAN_ID, 'cancel-request'); + + await cancelCodingPlanSubscription(user.id, result.subscriptionId); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, result.subscriptionId)); + const managedKeys = await db + .select() + .from(byok_api_keys) + .where(eq(byok_api_keys.kilo_user_id, user.id)); + + expect(subscription.status).toBe('active'); + expect(subscription.cancel_at_period_end).toBe(true); + expect(managedKeys).toHaveLength(1); + }); + + it('rejects unvalidated inventory credentials before they become available', async () => { + const validateCredential = jest.fn(async () => false); + + await expect( + uploadKeysToInventory(PLAN_ID, ['invalid-key'], { validateCredential }) + ).rejects.toThrow('failed validation'); + expect(validateCredential).toHaveBeenCalledWith('invalid-key'); + expect(await db.select().from(coding_plan_key_inventory)).toHaveLength(0); + }); + + it('rejects duplicate uploaded credentials using a secret keyed fingerprint', async () => { + await uploadKeysToInventory(PLAN_ID, ['duplicate-key'], validatedInventoryUpload); + await expect( + uploadKeysToInventory(PLAN_ID, ['duplicate-key'], validatedInventoryUpload) + ).rejects.toThrow('already present'); + const counts = await getKeyInventoryCounts(PLAN_ID); + expect(counts).toEqual([{ planId: PLAN_ID, status: 'available', count: 1 }]); + }); + + it('reveals only queued credentials and clears material after manual revocation succeeds', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS); + await seedInventoryKey('revoke-success-key'); + const activation = await subscribeToCodingPlan(user.id, PLAN_ID, 'revoke-success'); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, activation.subscriptionId)); + await terminateCodingPlanImmediately(activation.subscriptionId); + + await expect( + revealCredentialForManualRevocation(subscription.key_inventory_id!) + ).resolves.toEqual({ + apiKey: 'revoke-success-key', + }); + await markCredentialManuallyRevoked(subscription.key_inventory_id!); + const [credential] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); + + expect(credential.status).toBe('revoked'); + expect(credential.encrypted_api_key).toBeNull(); + expect(credential.revocation_attempt_count).toBe(1); + await expect( + revealCredentialForManualRevocation(subscription.key_inventory_id!) + ).rejects.toThrow('not eligible'); + }); + + it('keeps failed manual revocation terminal and retryable', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS); + await seedInventoryKey('revoke-failure-key'); + const activation = await subscribeToCodingPlan(user.id, PLAN_ID, 'revoke-failure'); + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, activation.subscriptionId)); + await terminateCodingPlanImmediately(activation.subscriptionId); + + await markCredentialManualRevocationFailed( + subscription.key_inventory_id!, + 'MiniMax admin request failed with api_key=secret-value' + ); + let [credential] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); + + expect(credential.status).toBe('revocation_failed'); + expect(credential.encrypted_api_key).not.toBeNull(); + expect(credential.revocation_attempt_count).toBe(1); + expect(credential.last_revocation_error).toContain('api_key=[redacted]'); + + await requeueManualCredentialRevocation(subscription.key_inventory_id!); + [credential] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); + expect(credential.status).toBe('revocation_pending'); + expect(credential.last_revocation_error).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/coding-plans/index.ts b/apps/web/src/lib/coding-plans/index.ts new file mode 100644 index 0000000000..051c7af1a4 --- /dev/null +++ b/apps/web/src/lib/coding-plans/index.ts @@ -0,0 +1,461 @@ +import 'server-only'; + +import { createHash, createHmac } from 'node:crypto'; +import { addDays } from 'date-fns'; +import { and, eq, inArray, sql } from 'drizzle-orm'; + +import { decryptApiKey, encryptApiKey } from '@/lib/ai-gateway/byok/encryption'; +import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; +import { validateTokenPlanPlusCredential } from '@/lib/coding-plans/inventory-validation'; +import { getCodingPlanPrice, type CodingPlanId } from '@/lib/coding-plans/pricing'; +import { db } from '@/lib/drizzle'; +import { maybeIssueKiloPassBonusFromUsageThreshold } from '@/lib/kilo-pass/usage-triggered-bonus'; +import { sentryLogger } from '@/lib/utils.server'; +import { + byok_api_keys, + coding_plan_availability_intents, + coding_plan_key_inventory, + coding_plan_subscriptions, + coding_plan_terms, + credit_transactions, +} from '@kilocode/db/schema'; + +const logInfo = sentryLogger('coding-plans', 'info'); +const logError = sentryLogger('coding-plans', 'error'); + +type CancellationReason = + | 'user_canceled' + | 'insufficient_credits' + | 'account_deleted' + | 'administrative_termination'; + +type SubscriptionOutcome = { + subscriptionId: string; + charged: boolean; +}; + +function idempotencyFingerprint(idempotencyKey: string): string { + return createHash('sha256').update(idempotencyKey).digest('hex'); +} + +function credentialFingerprint(apiKey: string): string { + if (!BYOK_ENCRYPTION_KEY) { + throw new Error('BYOK encryption is not configured'); + } + return createHmac('sha256', BYOK_ENCRYPTION_KEY).update(apiKey).digest('hex'); +} + +async function evaluateUsageBonus(userId: string): Promise { + try { + await maybeIssueKiloPassBonusFromUsageThreshold({ + kiloUserId: userId, + nowIso: new Date().toISOString(), + }); + } catch (error) { + logError('Kilo Pass bonus evaluation failed after coding plan charge', { + user_id: userId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export async function subscribeToCodingPlan( + userId: string, + planId: string, + idempotencyKey: string +): Promise<{ subscriptionId: string }> { + const plan = getCodingPlanPrice(planId); + if (!plan) { + throw new Error(`Plan "${planId}" is not available as a coding plan.`); + } + if (!BYOK_ENCRYPTION_KEY) { + throw new Error('BYOK encryption is not configured'); + } + + const requestKey = idempotencyFingerprint(idempotencyKey); + const outcome = await db.transaction(async tx => { + const { rows: lockedUsers } = await tx.execute<{ + total_microdollars_acquired: number; + microdollars_used: number; + }>(sql` + SELECT total_microdollars_acquired, microdollars_used + FROM kilocode_users + WHERE id = ${userId} + FOR UPDATE + `); + const lockedUser = lockedUsers[0]; + if (!lockedUser) { + throw new Error('User not found'); + } + + const [priorTerm] = await tx + .select({ subscriptionId: coding_plan_terms.subscription_id }) + .from(coding_plan_terms) + .where( + and( + eq(coding_plan_terms.user_id, userId), + eq(coding_plan_terms.plan_id, plan.planId), + eq(coding_plan_terms.idempotency_key, requestKey) + ) + ) + .limit(1); + if (priorTerm) { + return { + subscriptionId: priorTerm.subscriptionId, + charged: false, + } satisfies SubscriptionOutcome; + } + + const [liveSubscription] = await tx + .select() + .from(coding_plan_subscriptions) + .where( + and( + eq(coding_plan_subscriptions.user_id, userId), + eq(coding_plan_subscriptions.plan_id, plan.planId), + inArray(coding_plan_subscriptions.status, ['active', 'past_due']) + ) + ) + .limit(1); + + if (liveSubscription) { + throw new Error( + 'Token Plan Plus already has a live subscription. Later billing periods are purchased through renewal.' + ); + } + + const [existingProviderKey] = await tx + .select({ id: byok_api_keys.id }) + .from(byok_api_keys) + .where( + and(eq(byok_api_keys.kilo_user_id, userId), eq(byok_api_keys.provider_id, plan.providerId)) + ) + .limit(1); + if (existingProviderKey) { + throw new Error( + 'Remove your existing MiniMax BYOK key from /byok before subscribing to Token Plan Plus.' + ); + } + + const periodStart = new Date(); + const periodEnd = addDays(periodStart, plan.billingPeriodDays); + const periodStartIso = periodStart.toISOString(); + const periodEndIso = periodEnd.toISOString(); + const transactionId = crypto.randomUUID(); + + const { rows: chargedUsers } = await tx.execute<{ microdollars_used: number }>(sql` + UPDATE kilocode_users + SET microdollars_used = microdollars_used + ${plan.costMicrodollars} + WHERE id = ${userId} + AND total_microdollars_acquired - microdollars_used >= ${plan.costMicrodollars} + RETURNING microdollars_used + `); + if (chargedUsers.length === 0) { + throw new Error('Insufficient credit balance for this coding plan purchase.'); + } + + await tx.insert(credit_transactions).values({ + id: transactionId, + kilo_user_id: userId, + amount_microdollars: -plan.costMicrodollars, + is_free: false, + description: `Coding plan: ${plan.providerName} ${plan.name}`, + credit_category: `coding-plan:${plan.planId}:${requestKey}`, + check_category_uniqueness: true, + original_baseline_microdollars_used: lockedUser.microdollars_used, + }); + + const { rows: inventoryRows } = await tx.execute<{ + id: string; + encrypted_api_key: { iv: string; data: string; authTag: string } | null; + }>(sql` + UPDATE coding_plan_key_inventory + SET status = 'assigned', + assigned_to_user_id = ${userId}, + assigned_at = now(), + updated_at = now() + WHERE id = ( + SELECT id FROM coding_plan_key_inventory + WHERE plan_id = ${plan.planId} + AND status = 'available' + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, encrypted_api_key + `); + const inventoryKey = inventoryRows[0]; + if (!inventoryKey?.encrypted_api_key) { + throw new Error(`No managed credential is available for plan "${plan.planId}".`); + } + + const plaintext = decryptApiKey(inventoryKey.encrypted_api_key, BYOK_ENCRYPTION_KEY); + const [installedByok] = await tx + .insert(byok_api_keys) + .values({ + kilo_user_id: userId, + organization_id: null, + provider_id: plan.providerId, + encrypted_api_key: encryptApiKey(plaintext, BYOK_ENCRYPTION_KEY), + management_source: 'coding_plan', + created_by: userId, + }) + .onConflictDoNothing() + .returning({ id: byok_api_keys.id }); + if (!installedByok) { + throw new Error( + 'Remove your existing MiniMax BYOK key from /byok before subscribing to Token Plan Plus.' + ); + } + + const subscriptionId = crypto.randomUUID(); + await tx.insert(coding_plan_subscriptions).values({ + id: subscriptionId, + user_id: userId, + plan_id: plan.planId, + provider_id: plan.providerId, + key_inventory_id: inventoryKey.id, + installed_byok_key_id: installedByok.id, + status: 'active', + cost_microdollars: plan.costMicrodollars, + billing_period_days: plan.billingPeriodDays, + current_period_start: periodStartIso, + current_period_end: periodEndIso, + credit_renewal_at: periodEndIso, + }); + await tx.insert(coding_plan_terms).values({ + subscription_id: subscriptionId, + user_id: userId, + plan_id: plan.planId, + kind: 'activation', + idempotency_key: requestKey, + period_start: periodStartIso, + period_end: periodEndIso, + cost_microdollars: plan.costMicrodollars, + credit_transaction_id: transactionId, + }); + await tx + .delete(coding_plan_availability_intents) + .where( + and( + eq(coding_plan_availability_intents.user_id, userId), + eq(coding_plan_availability_intents.plan_id, plan.planId) + ) + ); + return { subscriptionId, charged: true } satisfies SubscriptionOutcome; + }); + + if (outcome.charged) { + await evaluateUsageBonus(userId); + } + logInfo('Coding plan purchase processed', { + user_id: userId, + planId: plan.planId, + subscriptionId: outcome.subscriptionId, + charged: outcome.charged, + }); + return { subscriptionId: outcome.subscriptionId }; +} + +export async function cancelCodingPlanSubscription( + userId: string, + subscriptionId: string +): Promise { + const result = await db + .update(coding_plan_subscriptions) + .set({ cancel_at_period_end: true }) + .where( + and( + eq(coding_plan_subscriptions.id, subscriptionId), + eq(coding_plan_subscriptions.user_id, userId), + eq(coding_plan_subscriptions.status, 'active') + ) + ); + if ((result.rowCount ?? 0) === 0) { + throw new Error('No active subscription found.'); + } + logInfo('Coding plan cancellation scheduled', { user_id: userId, subscriptionId }); +} + +export async function terminateCodingPlanImmediately( + subscriptionId: string, + reason: CancellationReason = 'administrative_termination' +): Promise { + const [subscription] = await db + .select({ + id: coding_plan_subscriptions.id, + installed_byok_key_id: coding_plan_subscriptions.installed_byok_key_id, + key_inventory_id: coding_plan_subscriptions.key_inventory_id, + }) + .from(coding_plan_subscriptions) + .where( + and( + eq(coding_plan_subscriptions.id, subscriptionId), + inArray(coding_plan_subscriptions.status, ['active', 'past_due']) + ) + ) + .limit(1); + if (!subscription) { + throw new Error('No live subscription found.'); + } + + await db.transaction(async tx => { + await tx + .update(coding_plan_subscriptions) + .set({ + status: 'canceled', + canceled_at: sql`now()`, + cancellation_reason: reason, + installed_byok_key_id: null, + cancel_at_period_end: false, + past_due_started_at: null, + payment_grace_expires_at: null, + auto_top_up_attempted_for_due: null, + }) + .where(eq(coding_plan_subscriptions.id, subscription.id)); + if (subscription.installed_byok_key_id) { + await tx + .delete(byok_api_keys) + .where( + and( + eq(byok_api_keys.id, subscription.installed_byok_key_id), + eq(byok_api_keys.management_source, 'coding_plan') + ) + ); + } + if (subscription.key_inventory_id) { + await tx + .update(coding_plan_key_inventory) + .set({ + status: 'revocation_pending', + revocation_requested_at: sql`now()`, + last_revocation_error: null, + }) + .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id)); + } + }); + + logInfo('Coding plan local access terminated; credential revocation pending', { + subscriptionId, + reason, + }); +} + +type InventoryCredentialValidator = (apiKey: string) => Promise; + +type InventoryUploadOptions = { + validateCredential?: InventoryCredentialValidator; +}; + +export async function uploadKeysToInventory( + planId: CodingPlanId, + plaintextKeys: string[], + options: InventoryUploadOptions = {} +): Promise<{ inserted: number }> { + const plan = getCodingPlanPrice(planId); + if (!plan) { + throw new Error(`Plan "${planId}" is not available as a coding plan.`); + } + if (!BYOK_ENCRYPTION_KEY) { + throw new Error('BYOK encryption is not configured'); + } + + const validateCredential = options.validateCredential ?? validateTokenPlanPlusCredential; + for (const plaintextKey of plaintextKeys) { + if (!(await validateCredential(plaintextKey))) { + throw new Error( + 'One or more MiniMax credentials failed validation. Confirm plan access and supported model behavior, then try again.' + ); + } + } + + const inserted = await db.transaction(async tx => { + const result = await tx + .insert(coding_plan_key_inventory) + .values( + plaintextKeys.map(key => ({ + plan_id: plan.planId, + provider_id: plan.providerId, + encrypted_api_key: encryptApiKey(key, BYOK_ENCRYPTION_KEY), + credential_fingerprint: credentialFingerprint(key), + status: 'available' as const, + })) + ) + .onConflictDoNothing({ target: coding_plan_key_inventory.credential_fingerprint }); + const insertedCount = result.rowCount ?? 0; + if (insertedCount !== plaintextKeys.length) { + throw new Error('One or more managed credentials are already present in inventory.'); + } + return insertedCount; + }); + + logInfo('Validated managed credentials uploaded to coding plan inventory', { + planId, + count: inserted, + }); + return { inserted }; +} + +export async function getAvailableCodingPlanIds(): Promise { + const rows = await db + .selectDistinct({ planId: coding_plan_key_inventory.plan_id }) + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.status, 'available')); + return rows.map(row => row.planId); +} + +export async function getCodingPlanAvailabilityIntentPlanIds(userId: string): Promise { + const rows = await db + .select({ planId: coding_plan_availability_intents.plan_id }) + .from(coding_plan_availability_intents) + .where(eq(coding_plan_availability_intents.user_id, userId)); + return rows.map(row => row.planId); +} + +export async function requestCodingPlanAvailabilityNotification( + userId: string, + planId: CodingPlanId +): Promise<{ requested: true }> { + const plan = getCodingPlanPrice(planId); + if (!plan) { + throw new Error(`Plan "${planId}" is not available as a coding plan.`); + } + + return db.transaction(async tx => { + const [availableCredential] = await tx + .select({ id: coding_plan_key_inventory.id }) + .from(coding_plan_key_inventory) + .where( + and( + eq(coding_plan_key_inventory.plan_id, plan.planId), + eq(coding_plan_key_inventory.status, 'available') + ) + ) + .limit(1); + if (availableCredential) { + throw new Error(`${plan.providerName} ${plan.name} is currently available.`); + } + + await tx + .insert(coding_plan_availability_intents) + .values({ user_id: userId, plan_id: plan.planId }) + .onConflictDoNothing(); + return { requested: true }; + }); +} + +export async function getKeyInventoryCounts( + planId?: CodingPlanId +): Promise> { + const { rows } = await db.execute<{ plan_id: string; status: string; count: string }>(sql` + SELECT plan_id, status, COUNT(*) AS count + FROM coding_plan_key_inventory + ${planId ? sql`WHERE plan_id = ${planId}` : sql``} + GROUP BY plan_id, status + ORDER BY plan_id, status + `); + return rows.map(row => ({ + planId: row.plan_id, + status: row.status, + count: Number.parseInt(row.count, 10), + })); +} diff --git a/apps/web/src/lib/coding-plans/inventory-validation.ts b/apps/web/src/lib/coding-plans/inventory-validation.ts new file mode 100644 index 0000000000..c618f934ae --- /dev/null +++ b/apps/web/src/lib/coding-plans/inventory-validation.ts @@ -0,0 +1,42 @@ +import 'server-only'; + +import { createGateway, generateText } from 'ai'; +import type { GatewayProviderOptions } from '@ai-sdk/gateway'; + +import { UserByokTestModels } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; +import { getVercelInferenceProviderConfigForUserByok } from '@/lib/ai-gateway/providers/vercel'; +import PROVIDERS from '@/lib/ai-gateway/providers/provider-definitions'; +import { sentryLogger } from '@/lib/utils.server'; + +const logWarning = sentryLogger('coding-plans-inventory-validation', 'warning'); +const MINIMAX_PROVIDER_ID = 'minimax'; + +export async function validateTokenPlanPlusCredential(apiKey: string): Promise { + const [finalProvider, byokList] = getVercelInferenceProviderConfigForUserByok({ + providerId: MINIMAX_PROVIDER_ID, + decryptedAPIKey: apiKey, + }); + + try { + const output = await generateText({ + model: createGateway({ apiKey: PROVIDERS.VERCEL_AI_GATEWAY.apiKey })( + UserByokTestModels[MINIMAX_PROVIDER_ID] + ), + prompt: 'Say hi', + maxOutputTokens: 1, + providerOptions: { + gateway: { + only: [finalProvider], + byok: { [finalProvider]: byokList }, + } satisfies GatewayProviderOptions, + }, + }); + + return output.finishReason === 'stop'; + } catch { + logWarning('MiniMax inventory credential validation failed', { + providerId: MINIMAX_PROVIDER_ID, + }); + return false; + } +} diff --git a/apps/web/src/lib/coding-plans/pricing.ts b/apps/web/src/lib/coding-plans/pricing.ts new file mode 100644 index 0000000000..2f957611af --- /dev/null +++ b/apps/web/src/lib/coding-plans/pricing.ts @@ -0,0 +1,36 @@ +export const CODING_PLAN_IDS = ['minimax-token-plan-plus'] as const; + +export type CodingPlanId = (typeof CODING_PLAN_IDS)[number]; +export type CodingPlanProviderId = 'minimax'; + +export type CodingPlanCatalogEntry = { + planId: CodingPlanId; + providerName: string; + name: string; + providerId: CodingPlanProviderId; + costMicrodollars: number; + billingPeriodDays: number; +}; + +export const CODING_PLAN_CATALOG = { + 'minimax-token-plan-plus': { + planId: 'minimax-token-plan-plus', + providerName: 'MiniMax', + name: 'Token Plan Plus', + providerId: 'minimax', + costMicrodollars: 20_000_000, + billingPeriodDays: 30, + }, +} satisfies Record; + +export function getCodingPlanCatalog(): CodingPlanCatalogEntry[] { + return CODING_PLAN_IDS.map(planId => CODING_PLAN_CATALOG[planId]); +} + +export function getCodingPlanPrice(planId: string): CodingPlanCatalogEntry | null { + return isCodingPlanId(planId) ? CODING_PLAN_CATALOG[planId] : null; +} + +export function isCodingPlanId(planId: string): planId is CodingPlanId { + return CODING_PLAN_IDS.some(candidate => candidate === planId); +} diff --git a/apps/web/src/lib/coding-plans/revocation.ts b/apps/web/src/lib/coding-plans/revocation.ts new file mode 100644 index 0000000000..5596babdab --- /dev/null +++ b/apps/web/src/lib/coding-plans/revocation.ts @@ -0,0 +1,158 @@ +import 'server-only'; + +import { and, desc, eq, inArray, sql } from 'drizzle-orm'; + +import { decryptApiKey } from '@/lib/ai-gateway/byok/encryption'; +import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; +import type { CodingPlanId } from '@/lib/coding-plans/pricing'; +import { db } from '@/lib/drizzle'; +import { coding_plan_key_inventory } from '@kilocode/db/schema'; + +export type ManualRevocationStatus = 'revocation_pending' | 'revocation_failed'; + +export async function listManualCredentialRevocations(input: { + planId?: CodingPlanId; + status?: ManualRevocationStatus; +}): Promise< + Array<{ + inventoryKeyId: string; + planId: string; + providerId: string; + status: ManualRevocationStatus; + revocationRequestedAt: string | null; + revokedAt: string | null; + revocationAttemptCount: number; + lastRevocationError: string | null; + updatedAt: string; + }> +> { + const rows = await db + .select({ + inventoryKeyId: coding_plan_key_inventory.id, + planId: coding_plan_key_inventory.plan_id, + providerId: coding_plan_key_inventory.provider_id, + status: coding_plan_key_inventory.status, + revocationRequestedAt: coding_plan_key_inventory.revocation_requested_at, + revokedAt: coding_plan_key_inventory.revoked_at, + revocationAttemptCount: coding_plan_key_inventory.revocation_attempt_count, + lastRevocationError: coding_plan_key_inventory.last_revocation_error, + updatedAt: coding_plan_key_inventory.updated_at, + }) + .from(coding_plan_key_inventory) + .where( + and( + input.status + ? eq(coding_plan_key_inventory.status, input.status) + : inArray(coding_plan_key_inventory.status, ['revocation_pending', 'revocation_failed']), + input.planId ? eq(coding_plan_key_inventory.plan_id, input.planId) : undefined + ) + ) + .orderBy(desc(coding_plan_key_inventory.revocation_requested_at)); + + return rows.map(row => ({ + ...row, + status: row.status === 'revocation_failed' ? 'revocation_failed' : 'revocation_pending', + })); +} + +export async function revealCredentialForManualRevocation( + inventoryKeyId: string +): Promise<{ apiKey: string }> { + const [inventoryKey] = await db + .select({ encryptedApiKey: coding_plan_key_inventory.encrypted_api_key }) + .from(coding_plan_key_inventory) + .where( + and( + eq(coding_plan_key_inventory.id, inventoryKeyId), + inArray(coding_plan_key_inventory.status, ['revocation_pending', 'revocation_failed']) + ) + ) + .limit(1); + + if (!inventoryKey?.encryptedApiKey) { + throw new Error('Credential is not eligible for manual revocation reveal.'); + } + + return { + apiKey: decryptApiKey(inventoryKey.encryptedApiKey, BYOK_ENCRYPTION_KEY), + }; +} + +export async function markCredentialManuallyRevoked(inventoryKeyId: string): Promise { + const result = await db + .update(coding_plan_key_inventory) + .set({ + status: 'revoked', + encrypted_api_key: null, + revoked_at: sql`now()`, + revocation_attempt_count: sql`${coding_plan_key_inventory.revocation_attempt_count} + 1`, + last_revocation_error: null, + }) + .where( + and( + eq(coding_plan_key_inventory.id, inventoryKeyId), + inArray(coding_plan_key_inventory.status, ['revocation_pending', 'revocation_failed']) + ) + ); + + if ((result.rowCount ?? 0) === 0) { + throw new Error('Credential is not eligible for manual revocation completion.'); + } +} + +export async function markCredentialManualRevocationFailed( + inventoryKeyId: string, + reason: string +): Promise { + const sanitizedReason = sanitizeManualFailureReason(reason); + const result = await db + .update(coding_plan_key_inventory) + .set({ + status: 'revocation_failed', + revocation_attempt_count: sql`${coding_plan_key_inventory.revocation_attempt_count} + 1`, + last_revocation_error: sanitizedReason, + }) + .where( + and( + eq(coding_plan_key_inventory.id, inventoryKeyId), + inArray(coding_plan_key_inventory.status, ['revocation_pending', 'revocation_failed']) + ) + ); + + if ((result.rowCount ?? 0) === 0) { + throw new Error('Credential is not eligible for manual revocation failure recording.'); + } +} + +export async function requeueManualCredentialRevocation(inventoryKeyId: string): Promise { + const result = await db + .update(coding_plan_key_inventory) + .set({ + status: 'revocation_pending', + revocation_requested_at: sql`now()`, + last_revocation_error: null, + }) + .where( + and( + eq(coding_plan_key_inventory.id, inventoryKeyId), + inArray(coding_plan_key_inventory.status, ['revocation_pending', 'revocation_failed']) + ) + ); + + if ((result.rowCount ?? 0) === 0) { + throw new Error('Credential is not eligible for manual revocation requeue.'); + } +} + +function sanitizeManualFailureReason(reason: string): string { + const normalized = reason.replace(/\s+/g, ' ').trim(); + if (!normalized) { + throw new Error('A sanitized failure reason is required.'); + } + + return normalized + .replace(/(bearer\s+)[^\s,;]+/gi, '$1[redacted]') + .replace(/(api[_ -]?key\s*[:=]\s*)[^\s,;]+/gi, '$1[redacted]') + .replace(/\bsk-[A-Za-z0-9_-]+\b/g, '[redacted]') + .slice(0, 300); +} diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts index 20be917978..2ab0c45ad4 100644 --- a/apps/web/src/lib/constants.ts +++ b/apps/web/src/lib/constants.ts @@ -32,7 +32,6 @@ export const TRIAL_DURATION_DAYS = 14; export const AUTOCOMPLETE_MODEL = 'codestral-2508'; export const ENABLE_DEPLOY_FEATURE = true; -export const ENABLE_CODING_PLAN_SUBSCRIPTIONS = false; export const IS_DEVELOPMENT = process.env.NODE_ENV === 'development'; diff --git a/apps/web/src/lib/user/index.test.ts b/apps/web/src/lib/user/index.test.ts index 09a18a3e8e..86dd7cef7d 100644 --- a/apps/web/src/lib/user/index.test.ts +++ b/apps/web/src/lib/user/index.test.ts @@ -75,6 +75,10 @@ import { model_experiment_request, stripe_early_fraud_warning_cases, stripe_early_fraud_warning_actions, + coding_plan_availability_intents, + coding_plan_key_inventory, + coding_plan_subscriptions, + byok_api_keys, } from '@kilocode/db/schema'; import { eq, count, sql } from 'drizzle-orm'; import { @@ -173,6 +177,10 @@ describe('User', () => { await db.delete(magic_link_tokens); await db.delete(bot_request_cloud_agent_sessions); await db.delete(bot_requests); + await db.delete(coding_plan_availability_intents); + await db.delete(coding_plan_subscriptions); + await db.delete(byok_api_keys); + await db.delete(coding_plan_key_inventory); await db.delete(stytch_fingerprints); await db.delete(kiloclaw_cli_runs); await db.delete(kiloclaw_email_log); @@ -1900,6 +1908,22 @@ describe('User', () => { expect(tokens).toHaveLength(0); }); + it('should delete Coding Plan availability notification intents', async () => { + const user = await insertTestUser(); + await db.insert(coding_plan_availability_intents).values({ + user_id: user.id, + plan_id: 'minimax-token-plan-plus', + }); + + await softDeleteUser(user.id); + + const intents = await db + .select() + .from(coding_plan_availability_intents) + .where(eq(coding_plan_availability_intents.user_id, user.id)); + expect(intents).toHaveLength(0); + }); + it('should delete github_branch_pull_requests owned by user', async () => { const user = await insertTestUser(); await db.insert(github_branch_pull_requests).values({ @@ -2624,6 +2648,74 @@ describe('User', () => { expect(userAfter!.google_user_email).toBe(user.google_user_email); }); + it('should terminate managed Coding Plan access and anonymize inventory on soft delete', async () => { + const { encryptApiKey } = await import('@/lib/ai-gateway/byok/encryption'); + const { BYOK_ENCRYPTION_KEY } = await import('@/lib/config.server'); + const user = await insertTestUser(); + const encrypted = encryptApiKey('test-key-for-gdpr', BYOK_ENCRYPTION_KEY); + const [inventoryKey] = await db + .insert(coding_plan_key_inventory) + .values({ + plan_id: 'minimax-token-plan-plus', + provider_id: 'minimax', + encrypted_api_key: encrypted, + credential_fingerprint: `gdpr-${randomUUID()}`, + status: 'assigned', + assigned_to_user_id: user.id, + assigned_at: new Date().toISOString(), + }) + .returning(); + const [byokKey] = await db + .insert(byok_api_keys) + .values({ + kilo_user_id: user.id, + provider_id: 'minimax', + encrypted_api_key: encrypted, + management_source: 'coding_plan', + created_by: user.id, + }) + .returning(); + + await db.insert(coding_plan_subscriptions).values({ + user_id: user.id, + plan_id: 'minimax-token-plan-plus', + provider_id: 'minimax', + key_inventory_id: inventoryKey.id, + installed_byok_key_id: byokKey.id, + status: 'active', + cost_microdollars: 20_000_000, + billing_period_days: 30, + current_period_start: new Date().toISOString(), + current_period_end: new Date(Date.now() + 30 * 86_400_000).toISOString(), + credit_renewal_at: new Date(Date.now() + 30 * 86_400_000).toISOString(), + }); + + await softDeleteUser(user.id); + + const [subscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.user_id, user.id)); + expect(subscription.status).toBe('canceled'); + expect(subscription.cancellation_reason).toBe('account_deleted'); + expect(subscription.canceled_at).not.toBeNull(); + expect(subscription.installed_byok_key_id).toBeNull(); + + const [retainedInventory] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, inventoryKey.id)); + expect(retainedInventory.status).toBe('revocation_pending'); + expect(retainedInventory.assigned_to_user_id).toBeNull(); + expect(retainedInventory.revocation_requested_at).not.toBeNull(); + + const byokKeys = await db + .select() + .from(byok_api_keys) + .where(eq(byok_api_keys.kilo_user_id, user.id)); + expect(byokKeys).toHaveLength(0); + }); + it('should throw SoftDeletePreconditionError for active KiloClaw instance even without live subscription', async () => { const user = await insertTestUser(); diff --git a/apps/web/src/lib/user/index.ts b/apps/web/src/lib/user/index.ts index 5e9fc801d4..1f34d1b5cb 100644 --- a/apps/web/src/lib/user/index.ts +++ b/apps/web/src/lib/user/index.ts @@ -78,6 +78,8 @@ import { github_branch_pull_requests, model_eval_ingestions, stripe_early_fraud_warning_cases, + coding_plan_availability_intents, + coding_plan_subscriptions, } from '@kilocode/db/schema'; import { eq, and, inArray, isNotNull, isNull, sql, or, gte, count } from 'drizzle-orm'; import { allow_fake_login, IS_DEVELOPMENT } from '@/lib/constants'; @@ -692,7 +694,10 @@ export async function createOrUpdateUser( }); // Set up user identification via user ID - posthogClient.alias({ distinctId: savedUser.google_user_email, alias: savedUser.id }); + posthogClient.alias({ + distinctId: savedUser.google_user_email, + alias: savedUser.id, + }); await tryVerifyDiscordGuildMembership(args.provider, args.provider_account_id, savedUser.id); @@ -821,7 +826,7 @@ export class SoftDeletePreconditionError extends Error { * auto_triage/fix_tickets, slack_bot_requests, bot_requests, * cloud_agent_code_reviews, device_auth_requests, auto_top_up_configs, * kiloclaw_instances/inbound_email_aliases/access_codes, user_period_cache, - * kilo_pass_scheduled_changes) + * kilo_pass_scheduled_changes, coding_plan_availability_intents) * - kiloclaw_instances.admin_size_override JSONB (contains admin actorEmail * + free-form reason; cleared on the deleted user's retained destroyed * instances, AND on any other instances where this user was the admin @@ -860,7 +865,10 @@ export async function softDeleteUser(userId: string) { // trialing — the user may have a running Fly instance, and deleting the // row without destroying the instance would orphan it. const liveClawSubscriptions = await tx - .select({ id: kiloclaw_subscriptions.id, status: kiloclaw_subscriptions.status }) + .select({ + id: kiloclaw_subscriptions.id, + status: kiloclaw_subscriptions.status, + }) .from(kiloclaw_subscriptions) .where( and( @@ -1022,7 +1030,43 @@ export async function softDeleteUser(userId: string) { await tx .delete(platform_integrations) .where(eq(platform_integrations.owned_by_user_id, userId)); + await tx.execute(sql` + UPDATE coding_plan_key_inventory + SET status = 'revocation_pending', + assigned_to_user_id = NULL, + revocation_requested_at = now(), + last_revocation_error = NULL, + updated_at = now() + WHERE id IN ( + SELECT key_inventory_id + FROM coding_plan_subscriptions + WHERE user_id = ${userId} + AND status IN ('active', 'past_due') + AND key_inventory_id IS NOT NULL + ) + `); + await tx + .update(coding_plan_subscriptions) + .set({ + status: 'canceled', + canceled_at: sql`now()`, + cancellation_reason: 'account_deleted', + installed_byok_key_id: null, + cancel_at_period_end: false, + past_due_started_at: null, + payment_grace_expires_at: null, + auto_top_up_attempted_for_due: null, + }) + .where( + and( + eq(coding_plan_subscriptions.user_id, userId), + inArray(coding_plan_subscriptions.status, ['active', 'past_due']) + ) + ); await tx.delete(byok_api_keys).where(eq(byok_api_keys.kilo_user_id, userId)); + await tx + .delete(coding_plan_availability_intents) + .where(eq(coding_plan_availability_intents.user_id, userId)); await tx.delete(agent_configs).where(eq(agent_configs.owned_by_user_id, userId)); await tx.delete(webhook_events).where(eq(webhook_events.owned_by_user_id, userId)); await tx @@ -1381,7 +1425,9 @@ export async function getAllUserProviders(email: string): Promise<{ * @returns The WorkOS organization, or null if not found */ export async function getWorkOSOrganization(domain: string) { - const orgResult = await workos.organizations.listOrganizations({ domains: [domain] }); + const orgResult = await workos.organizations.listOrganizations({ + domains: [domain], + }); if (orgResult.data.length === 1) { return orgResult.data[0]; @@ -1448,7 +1494,9 @@ async function tryVerifyDiscordGuildMembership( if (isMember) { await db .update(kilocode_users) - .set({ discord_server_membership_verified_at: new Date().toISOString() }) + .set({ + discord_server_membership_verified_at: new Date().toISOString(), + }) .where(eq(kilocode_users.id, kiloUserId)); } } catch (error) { diff --git a/apps/web/src/routers/byok-router.test.ts b/apps/web/src/routers/byok-router.test.ts index 787dd38ead..1119b9942e 100644 --- a/apps/web/src/routers/byok-router.test.ts +++ b/apps/web/src/routers/byok-router.test.ts @@ -1,12 +1,19 @@ -import { describe, test, expect, beforeAll, afterEach } from '@jest/globals'; +import { describe, test, expect, beforeAll, afterEach, jest } from '@jest/globals'; import { createCallerForUser } from '@/routers/test-utils'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { createTestOrganization } from '@/tests/helpers/organization.helper'; import { addUserToOrganization } from '@/lib/organizations/organizations'; import { db } from '@/lib/drizzle'; -import { byok_api_keys, organization_audit_logs } from '@kilocode/db/schema'; +import { + byok_api_keys, + coding_plan_key_inventory, + coding_plan_subscriptions, + organization_audit_logs, +} from '@kilocode/db/schema'; import { eq, and } from 'drizzle-orm'; import type { User, Organization } from '@kilocode/db/schema'; +import { encryptApiKey } from '@/lib/ai-gateway/byok/encryption'; +import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; describe('BYOK Router', () => { let ownerUser: User; @@ -41,9 +48,16 @@ describe('BYOK Router', () => { }); afterEach(async () => { + await db + .delete(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.user_id, ownerUser.id)); + await db + .delete(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.assigned_to_user_id, ownerUser.id)); // Clean up BYOK keys after each test await db.delete(byok_api_keys).where(eq(byok_api_keys.organization_id, organizationA.id)); await db.delete(byok_api_keys).where(eq(byok_api_keys.organization_id, organizationB.id)); + await db.delete(byok_api_keys).where(eq(byok_api_keys.kilo_user_id, ownerUser.id)); }); describe('list', () => { @@ -462,6 +476,125 @@ describe('BYOK Router', () => { }); }); + describe('key validation error safety', () => { + test('does not return upstream provider error bodies from API key tests', async () => { + const caller = await createCallerForUser(ownerUser.id); + const key = await caller.byok.create({ provider_id: 'codestral', api_key: 'stored-secret' }); + const fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue( + new Response('authorization=stored-secret provider detail', { status: 401 }) + ); + + try { + await expect(caller.byok.testApiKey({ id: key.id })).resolves.toEqual({ + success: false, + message: + 'API key test failed. Check the credential and supported models, then try again.', + }); + } finally { + fetchSpy.mockRestore(); + } + }); + }); + + describe('Token Plan Plus-installed MiniMax credentials', () => { + test('rejects removed dedicated provider identity in manual creation requests', async () => { + const caller = await createCallerForUser(ownerUser.id); + + await expect( + caller.byok.create({ + provider_id: 'minimax-token-plan-plus-managed' as never, + api_key: 'not-supported', + }) + ).rejects.toThrow(); + }); + + async function createInstalledMiniMaxKey() { + const encrypted = encryptApiKey('installed-minimax-key', BYOK_ENCRYPTION_KEY); + const [inventory] = await db + .insert(coding_plan_key_inventory) + .values({ + plan_id: 'minimax-token-plan-plus', + provider_id: 'minimax', + encrypted_api_key: encrypted, + credential_fingerprint: crypto.randomUUID(), + status: 'assigned', + assigned_to_user_id: ownerUser.id, + }) + .returning(); + const [key] = await db + .insert(byok_api_keys) + .values({ + kilo_user_id: ownerUser.id, + provider_id: 'minimax', + encrypted_api_key: encrypted, + management_source: 'coding_plan', + created_by: ownerUser.id, + }) + .returning(); + const now = new Date().toISOString(); + const [subscription] = await db + .insert(coding_plan_subscriptions) + .values({ + user_id: ownerUser.id, + plan_id: 'minimax-token-plan-plus', + provider_id: 'minimax', + key_inventory_id: inventory.id, + installed_byok_key_id: key.id, + status: 'active', + cost_microdollars: 20_000_000, + billing_period_days: 30, + current_period_start: now, + current_period_end: now, + credit_renewal_at: now, + }) + .returning(); + return { key, subscription }; + } + + test('identifies installed origin and allows disable without affecting ownership', async () => { + const { key } = await createInstalledMiniMaxKey(); + const caller = await createCallerForUser(ownerUser.id); + const listed = await caller.byok.list({}); + + expect(listed[0].provider_id).toBe('minimax'); + expect(listed[0].management_source).toBe('coding_plan'); + const disabled = await caller.byok.setEnabled({ id: key.id, is_enabled: false }); + expect(disabled.is_enabled).toBe(false); + expect(disabled.management_source).toBe('coding_plan'); + }); + + test('transfers cleanup ownership when installed MiniMax credential is updated', async () => { + const { key, subscription } = await createInstalledMiniMaxKey(); + const caller = await createCallerForUser(ownerUser.id); + + const updated = await caller.byok.update({ id: key.id, api_key: 'replacement-key' }); + const [updatedSubscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscription.id)); + + expect(updated.management_source).toBe('user'); + expect(updatedSubscription.status).toBe('active'); + expect(updatedSubscription.installed_byok_key_id).toBeNull(); + }); + + test('allows deleting installed MiniMax key without canceling subscription', async () => { + const { key, subscription } = await createInstalledMiniMaxKey(); + const caller = await createCallerForUser(ownerUser.id); + + await expect(caller.byok.delete({ id: key.id })).resolves.toEqual({ success: true }); + const [updatedSubscription] = await db + .select() + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, subscription.id)); + + expect(updatedSubscription.status).toBe('active'); + expect(updatedSubscription.installed_byok_key_id).toBeNull(); + }); + }); + describe('cross-organization security', () => { test('should not allow user from org A to access keys from org B', async () => { const callerA = await createCallerForUser(ownerUser.id); diff --git a/apps/web/src/routers/byok-router.ts b/apps/web/src/routers/byok-router.ts index c9b37e0e99..d5d746bbdd 100644 --- a/apps/web/src/routers/byok-router.ts +++ b/apps/web/src/routers/byok-router.ts @@ -3,7 +3,12 @@ import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { TRPCError } from '@trpc/server'; import * as z from 'zod'; import { db } from '@/lib/drizzle'; -import { byok_api_keys, MODELS_BY_PROVIDER_ADMIN_URL } from '@kilocode/db/schema'; +import { sentryLogger } from '@/lib/utils.server'; +import { + byok_api_keys, + coding_plan_subscriptions, + MODELS_BY_PROVIDER_ADMIN_URL, +} from '@kilocode/db/schema'; import { eq } from 'drizzle-orm'; import { encryptApiKey } from '@/lib/ai-gateway/byok/encryption'; import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; @@ -43,6 +48,9 @@ import { const CODESTRAL_FIM_URL = 'https://codestral.mistral.ai/v1/fim/completions'; const CODESTRAL_TEST_MODEL = 'codestral-2508'; +const GENERIC_TEST_FAILURE_MESSAGE = + 'API key test failed. Check the credential and supported models, then try again.'; +const logByokWarning = sentryLogger('byok-key-test', 'warning'); async function testCodestralApiKey(apiKey: string): Promise<{ success: boolean; message: string }> { try { @@ -60,26 +68,22 @@ async function testCodestralApiKey(apiKey: string): Promise<{ success: boolean; stream: false, }), }); - // Always drain the body so the underlying Undici connection is released, - // regardless of whether we care about the response payload. - const bodyText = await res.text().catch(() => ''); + // Always drain the body so the underlying Undici connection is released. + await res.text().catch(() => ''); if (res.ok) { return { success: true, message: `API key test success. Provider: codestral. Model: ${CODESTRAL_TEST_MODEL}.`, }; } - return { - success: false, - message: `API key (codestral) test failed with status ${res.status}${ - bodyText ? `: ${bodyText.slice(0, 200)}` : '' - }`, - }; - } catch (e) { - return { - success: false, - message: `API key (codestral) test failed with: ${e instanceof Error ? e.message : String(e)}`, - }; + logByokWarning('Codestral BYOK key test failed', { + providerId: 'codestral', + status: res.status, + }); + return { success: false, message: GENERIC_TEST_FAILURE_MESSAGE }; + } catch { + logByokWarning('Codestral BYOK key test request failed', { providerId: 'codestral' }); + return { success: false, message: GENERIC_TEST_FAILURE_MESSAGE }; } } @@ -158,6 +162,7 @@ export const byokRouter = createTRPCRouter({ created_at: byok_api_keys.created_at, updated_at: byok_api_keys.updated_at, created_by: byok_api_keys.created_by, + management_source: byok_api_keys.management_source, is_enabled: byok_api_keys.is_enabled, }) .from(byok_api_keys) @@ -204,6 +209,7 @@ export const byokRouter = createTRPCRouter({ created_at: byok_api_keys.created_at, updated_at: byok_api_keys.updated_at, created_by: byok_api_keys.created_by, + management_source: byok_api_keys.management_source, is_enabled: byok_api_keys.is_enabled, }); @@ -242,6 +248,7 @@ export const byokRouter = createTRPCRouter({ organization_id: byok_api_keys.organization_id, kilo_user_id: byok_api_keys.kilo_user_id, provider_id: byok_api_keys.provider_id, + management_source: byok_api_keys.management_source, }) .from(byok_api_keys) .where(eq(byok_api_keys.id, id)); @@ -270,24 +277,40 @@ export const byokRouter = createTRPCRouter({ } } - // Encrypt the new API key const encrypted = encryptApiKey(api_key, BYOK_ENCRYPTION_KEY); + const transfersCodingPlanOwnership = + !organizationId && existingKey.management_source === 'coding_plan'; + + const updatedKey = await db.transaction(async tx => { + const [updated] = await tx + .update(byok_api_keys) + .set( + transfersCodingPlanOwnership + ? { encrypted_api_key: encrypted, management_source: 'user' } + : { encrypted_api_key: encrypted } + ) + .where(eq(byok_api_keys.id, id)) + .returning({ + id: byok_api_keys.id, + provider_id: byok_api_keys.provider_id, + created_at: byok_api_keys.created_at, + updated_at: byok_api_keys.updated_at, + created_by: byok_api_keys.created_by, + management_source: byok_api_keys.management_source, + is_enabled: byok_api_keys.is_enabled, + }); - // Update in database - const [updatedKey] = await db - .update(byok_api_keys) - .set({ - encrypted_api_key: encrypted, - }) - .where(eq(byok_api_keys.id, id)) - .returning({ - id: byok_api_keys.id, - provider_id: byok_api_keys.provider_id, - created_at: byok_api_keys.created_at, - updated_at: byok_api_keys.updated_at, - created_by: byok_api_keys.created_by, - is_enabled: byok_api_keys.is_enabled, - }); + if (!updated) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'BYOK key not found' }); + } + if (transfersCodingPlanOwnership) { + await tx + .update(coding_plan_subscriptions) + .set({ installed_byok_key_id: null }) + .where(eq(coding_plan_subscriptions.installed_byok_key_id, id)); + } + return updated; + }); // Create audit log only for organization keys if (existingKey.organization_id) { @@ -322,6 +345,7 @@ export const byokRouter = createTRPCRouter({ organization_id: byok_api_keys.organization_id, kilo_user_id: byok_api_keys.kilo_user_id, provider_id: byok_api_keys.provider_id, + management_source: byok_api_keys.management_source, }) .from(byok_api_keys) .where(eq(byok_api_keys.id, id)); @@ -361,6 +385,7 @@ export const byokRouter = createTRPCRouter({ created_at: byok_api_keys.created_at, updated_at: byok_api_keys.updated_at, created_by: byok_api_keys.created_by, + management_source: byok_api_keys.management_source, is_enabled: byok_api_keys.is_enabled, }); @@ -397,6 +422,7 @@ export const byokRouter = createTRPCRouter({ kilo_user_id: byok_api_keys.kilo_user_id, provider_id: byok_api_keys.provider_id, encrypted_api_key: byok_api_keys.encrypted_api_key, + management_source: byok_api_keys.management_source, }) .from(byok_api_keys) .where(eq(byok_api_keys.id, id)); @@ -461,10 +487,10 @@ export const byokRouter = createTRPCRouter({ }); if (output.finishReason !== 'stop') { - return { - success: false, - message: `API key (${decryptedKey.providerId}) test failed with finish reason: ${output.finishReason}`, - }; + logByokWarning('BYOK key test returned an unsuccessful completion', { + providerId: decryptedKey.providerId, + }); + return { success: false, message: GENERIC_TEST_FAILURE_MESSAGE }; } const metadata = output.providerMetadata?.gateway?.routing as @@ -473,14 +499,11 @@ export const byokRouter = createTRPCRouter({ return { success: true, - message: `API key test success. Provider: ${metadata?.finalProvider ?? finalProvider}. Model: ${metadata?.originalModelId ?? model.modelId}. Model output: ${output.text}`, - }; - } catch (e) { - console.error(e); - return { - success: false, - message: `API key (${decryptedKey.providerId}) test failed with: ${e instanceof Error ? e.message : e}`, + message: `API key test success. Provider: ${metadata?.finalProvider ?? finalProvider}. Model: ${metadata?.originalModelId ?? model.modelId}.`, }; + } catch { + logByokWarning('BYOK key test request failed', { providerId: decryptedKey.providerId }); + return { success: false, message: GENERIC_TEST_FAILURE_MESSAGE }; } }), @@ -501,6 +524,7 @@ export const byokRouter = createTRPCRouter({ organization_id: byok_api_keys.organization_id, kilo_user_id: byok_api_keys.kilo_user_id, provider_id: byok_api_keys.provider_id, + management_source: byok_api_keys.management_source, }) .from(byok_api_keys) .where(eq(byok_api_keys.id, id)); diff --git a/apps/web/src/routers/coding-plans-router.test.ts b/apps/web/src/routers/coding-plans-router.test.ts new file mode 100644 index 0000000000..f61ec4ab6e --- /dev/null +++ b/apps/web/src/routers/coding-plans-router.test.ts @@ -0,0 +1,269 @@ +/* eslint-disable drizzle/enforce-delete-with-where */ +import { eq } from 'drizzle-orm'; +import { encryptApiKey } from '@/lib/ai-gateway/byok/encryption'; +import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; +import { db } from '@/lib/drizzle'; +import { uploadKeysToInventory } from '@/lib/coding-plans'; +import { createCallerForUser } from '@/routers/test-utils'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + byok_api_keys, + coding_plan_availability_intents, + coding_plan_key_inventory, + coding_plan_subscriptions, + coding_plan_terms, + credit_transactions, + kilocode_users, +} from '@kilocode/db/schema'; + +const PLAN_ID = 'minimax-token-plan-plus'; +const COST_MICRODOLLARS = 20_000_000; + +afterEach(async () => { + await db.delete(coding_plan_availability_intents); + await db.delete(coding_plan_terms); + await db.delete(coding_plan_subscriptions); + await db.delete(byok_api_keys); + await db.delete(coding_plan_key_inventory); + await db.delete(credit_transactions); + await db.delete(kilocode_users); +}); + +describe('coding plans router', () => { + it('serves the configured Coding Plan catalog in Kilo Credits', async () => { + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + + await expect(caller.codingPlans.catalog()).resolves.toEqual([ + { + planId: PLAN_ID, + providerName: 'MiniMax', + name: 'Token Plan Plus', + providerId: 'minimax', + costKiloCredits: 20, + billingPeriodDays: 30, + availabilityStatus: 'sold_out', + notificationRequested: false, + }, + ]); + }); + + it('reports available capacity without exposing inventory and rejects notify requests while in stock', async () => { + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + await uploadKeysToInventory(PLAN_ID, [`catalog-available-${crypto.randomUUID()}`], { + validateCredential: async () => true, + }); + + await expect(caller.codingPlans.catalog()).resolves.toEqual([ + expect.objectContaining({ + planId: PLAN_ID, + availabilityStatus: 'available', + notificationRequested: false, + }), + ]); + await expect( + caller.codingPlans.requestAvailabilityNotification({ planId: PLAN_ID }) + ).rejects.toThrow('currently available'); + }); + + it('persists one notification intent when a sold-out user requests availability updates', async () => { + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + + await expect( + caller.codingPlans.requestAvailabilityNotification({ planId: PLAN_ID }) + ).resolves.toEqual({ requested: true }); + await expect( + caller.codingPlans.requestAvailabilityNotification({ planId: PLAN_ID }) + ).resolves.toEqual({ requested: true }); + + const intents = await db.select().from(coding_plan_availability_intents); + expect(intents).toHaveLength(1); + expect(intents[0]).toMatchObject({ user_id: user.id, plan_id: PLAN_ID }); + await expect(caller.codingPlans.catalog()).resolves.toEqual([ + expect.objectContaining({ + planId: PLAN_ID, + availabilityStatus: 'sold_out', + notificationRequested: true, + }), + ]); + }); + + it('clears an availability notification intent when the user later subscribes', async () => { + const user = await insertTestUser({ + total_microdollars_acquired: COST_MICRODOLLARS, + microdollars_used: 0, + }); + const caller = await createCallerForUser(user.id); + await caller.codingPlans.requestAvailabilityNotification({ planId: PLAN_ID }); + await uploadKeysToInventory(PLAN_ID, [`notify-activation-${crypto.randomUUID()}`], { + validateCredential: async () => true, + }); + + await caller.codingPlans.subscribe({ planId: PLAN_ID, idempotencyKey: 'notify-activation' }); + + expect(await db.select().from(coding_plan_availability_intents)).toHaveLength(0); + }); + + it('rejects purchase while a disabled personal MiniMax BYOK key occupies setup', async () => { + const user = await insertTestUser({ + total_microdollars_acquired: COST_MICRODOLLARS, + microdollars_used: 0, + }); + const caller = await createCallerForUser(user.id); + const key = await caller.byok.create({ provider_id: 'minimax', api_key: 'existing-key' }); + await caller.byok.setEnabled({ id: key.id, is_enabled: false }); + await uploadKeysToInventory(PLAN_ID, [`unused-router-key-${crypto.randomUUID()}`], { + validateCredential: async () => true, + }); + + await expect( + caller.codingPlans.subscribe({ planId: PLAN_ID, idempotencyKey: 'blocked-slot' }) + ).rejects.toThrow('Remove your existing MiniMax BYOK key'); + const [savedUser] = await db.select().from(kilocode_users); + const subscriptions = await db.select().from(coding_plan_subscriptions); + const terms = await db.select().from(coding_plan_terms); + + expect(savedUser.microdollars_used).toBe(0); + expect(subscriptions).toHaveLength(0); + expect(terms).toHaveLength(0); + }); + + it('creates and reads only the owner subscription and credit billing history', async () => { + const owner = await insertTestUser({ + total_microdollars_acquired: COST_MICRODOLLARS, + microdollars_used: 0, + }); + const otherUser = await insertTestUser(); + await uploadKeysToInventory(PLAN_ID, [`router-managed-key-${crypto.randomUUID()}`], { + validateCredential: async () => true, + }); + const ownerCaller = await createCallerForUser(owner.id); + const otherCaller = await createCallerForUser(otherUser.id); + + const activation = await ownerCaller.codingPlans.subscribe({ + planId: PLAN_ID, + idempotencyKey: 'router-activation-request', + }); + const subscriptions = await ownerCaller.codingPlans.listSubscriptions(); + const detail = await ownerCaller.codingPlans.getSubscriptionDetail({ + subscriptionId: activation.subscriptionId, + }); + const billing = await ownerCaller.codingPlans.getBillingHistory({ + subscriptionId: activation.subscriptionId, + }); + + expect(subscriptions).toHaveLength(1); + expect(detail).toMatchObject({ + id: activation.subscriptionId, + planId: PLAN_ID, + planName: 'Token Plan Plus', + providerName: 'MiniMax', + providerId: 'minimax', + routeLabel: 'MiniMax via Kilo Gateway', + hasInstalledByokKey: true, + status: 'active', + costKiloCredits: 20, + billingPeriodDays: 30, + cancelAtPeriodEnd: false, + }); + expect(detail.currentPeriodEnd).toContain('T'); + expect(billing).toEqual({ + entries: [ + { + kind: 'credits', + id: expect.any(String), + date: expect.stringContaining('T'), + amountMicrodollars: COST_MICRODOLLARS, + description: 'Coding plan: MiniMax Token Plan Plus', + }, + ], + hasMore: false, + cursor: null, + }); + + const [installedKey] = await db + .select({ id: byok_api_keys.id }) + .from(byok_api_keys) + .where(eq(byok_api_keys.kilo_user_id, owner.id)) + .limit(1); + if (!installedKey) { + throw new Error('Expected Coding Plan activation to install a BYOK key'); + } + await ownerCaller.byok.update({ id: installedKey.id, api_key: 'owner-replacement-key' }); + await expect( + ownerCaller.codingPlans.getSubscriptionDetail({ subscriptionId: activation.subscriptionId }) + ).resolves.toMatchObject({ hasInstalledByokKey: false }); + + await expect( + otherCaller.codingPlans.getSubscriptionDetail({ subscriptionId: activation.subscriptionId }) + ).rejects.toThrow('Coding Plan subscription not found.'); + await expect( + otherCaller.codingPlans.getBillingHistory({ subscriptionId: activation.subscriptionId }) + ).rejects.toThrow('Coding Plan subscription not found.'); + }); + + it('rejects a second live purchase instead of creating a prepaid extension', async () => { + const owner = await insertTestUser({ + total_microdollars_acquired: COST_MICRODOLLARS * 2, + microdollars_used: 0, + }); + await uploadKeysToInventory(PLAN_ID, [`second-purchase-key-${crypto.randomUUID()}`], { + validateCredential: async () => true, + }); + const caller = await createCallerForUser(owner.id); + await caller.codingPlans.subscribe({ planId: PLAN_ID, idempotencyKey: 'first-purchase' }); + + await expect( + caller.codingPlans.subscribe({ planId: PLAN_ID, idempotencyKey: 'new-purchase' }) + ).rejects.toThrow('already has a live subscription'); + expect(await db.select().from(coding_plan_terms)).toHaveLength(1); + }); + + it('restricts manual remediation and returns raw material only from explicit admin reveal', async () => { + const admin = await insertTestUser({ is_admin: true }); + const user = await insertTestUser(); + const [workItem] = await db + .insert(coding_plan_key_inventory) + .values({ + plan_id: PLAN_ID, + provider_id: 'minimax', + encrypted_api_key: encryptApiKey('manual-reveal-value', BYOK_ENCRYPTION_KEY), + credential_fingerprint: crypto.randomUUID(), + status: 'revocation_pending', + revocation_requested_at: new Date().toISOString(), + }) + .returning(); + const adminCaller = await createCallerForUser(admin.id); + const userCaller = await createCallerForUser(user.id); + + await expect(userCaller.codingPlans.adminRevocationQueue({})).rejects.toThrow(); + const queue = await adminCaller.codingPlans.adminRevocationQueue({}); + expect(queue).toHaveLength(1); + expect(queue[0]).not.toHaveProperty('encrypted_api_key'); + await expect( + adminCaller.codingPlans.adminRevealRevocationCredential({ inventoryKeyId: workItem.id }) + ).resolves.toEqual({ apiKey: 'manual-reveal-value' }); + + await adminCaller.codingPlans.adminMarkRevocationFailed({ + inventoryKeyId: workItem.id, + reason: 'Failed with bearer secret-token', + }); + const [failed] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, workItem.id)); + expect(failed.status).toBe('revocation_failed'); + expect(failed.last_revocation_error).toContain('bearer [redacted]'); + + await adminCaller.codingPlans.adminRequeueRevocation({ inventoryKeyId: workItem.id }); + await adminCaller.codingPlans.adminMarkRevocationComplete({ inventoryKeyId: workItem.id }); + const [revoked] = await db + .select() + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, workItem.id)); + expect(revoked.status).toBe('revoked'); + expect(revoked.encrypted_api_key).toBeNull(); + }); +}); diff --git a/apps/web/src/routers/coding-plans-router.ts b/apps/web/src/routers/coding-plans-router.ts new file mode 100644 index 0000000000..6d4195a766 --- /dev/null +++ b/apps/web/src/routers/coding-plans-router.ts @@ -0,0 +1,357 @@ +import { and, desc, eq } from 'drizzle-orm'; +import { TRPCError } from '@trpc/server'; +import * as z from 'zod'; + +import { + cancelCodingPlanSubscription, + getAvailableCodingPlanIds, + getCodingPlanAvailabilityIntentPlanIds, + getKeyInventoryCounts, + requestCodingPlanAvailabilityNotification, + subscribeToCodingPlan, + terminateCodingPlanImmediately, + uploadKeysToInventory, +} from '@/lib/coding-plans'; +import { + listManualCredentialRevocations, + markCredentialManuallyRevoked, + markCredentialManualRevocationFailed, + requeueManualCredentialRevocation, + revealCredentialForManualRevocation, +} from '@/lib/coding-plans/revocation'; +import { + CODING_PLAN_IDS, + getCodingPlanCatalog, + getCodingPlanPrice, +} from '@/lib/coding-plans/pricing'; +import { db } from '@/lib/drizzle'; +import { billingHistoryResponseSchema } from '@/lib/subscriptions/subscription-center'; +import { baseProcedure, adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { + coding_plan_subscriptions, + coding_plan_terms, + credit_transactions, +} from '@kilocode/db/schema'; + +const CodingPlanIdSchema = z.enum(CODING_PLAN_IDS); +const SubscriptionIdSchema = z.string().uuid(); +const BillingHistoryInputSchema = z.object({ + subscriptionId: SubscriptionIdSchema, + cursor: z.string().optional(), +}); + +const codingPlanSubscriptionColumns = { + id: coding_plan_subscriptions.id, + planId: coding_plan_subscriptions.plan_id, + providerId: coding_plan_subscriptions.provider_id, + installedByokKeyId: coding_plan_subscriptions.installed_byok_key_id, + status: coding_plan_subscriptions.status, + costMicrodollars: coding_plan_subscriptions.cost_microdollars, + billingPeriodDays: coding_plan_subscriptions.billing_period_days, + currentPeriodStart: coding_plan_subscriptions.current_period_start, + currentPeriodEnd: coding_plan_subscriptions.current_period_end, + creditRenewalAt: coding_plan_subscriptions.credit_renewal_at, + cancelAtPeriodEnd: coding_plan_subscriptions.cancel_at_period_end, + paymentGraceExpiresAt: coding_plan_subscriptions.payment_grace_expires_at, + canceledAt: coding_plan_subscriptions.canceled_at, + cancellationReason: coding_plan_subscriptions.cancellation_reason, + createdAt: coding_plan_subscriptions.created_at, +}; + +type CodingPlanSubscriptionRow = Awaited>[number]; + +function inKiloCredits(microdollars: number): number { + return microdollars / 1_000_000; +} + +function toIsoTimestamp(value: string): string { + return new Date(value).toISOString(); +} + +function toNullableIsoTimestamp(value: string | null): string | null { + return value ? toIsoTimestamp(value) : null; +} + +function toAvailabilityStatus(isAvailable: boolean): 'available' | 'sold_out' { + return isAvailable ? 'available' : 'sold_out'; +} + +async function listOwnedSubscriptions(userId: string) { + return db + .select(codingPlanSubscriptionColumns) + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.user_id, userId)); +} + +async function getOwnedSubscription(userId: string, subscriptionId: string) { + const [subscription] = await db + .select(codingPlanSubscriptionColumns) + .from(coding_plan_subscriptions) + .where( + and( + eq(coding_plan_subscriptions.id, subscriptionId), + eq(coding_plan_subscriptions.user_id, userId) + ) + ) + .limit(1); + + if (!subscription) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Coding Plan subscription not found.' }); + } + + return subscription; +} + +function toCodingPlanSubscriptionView(subscription: CodingPlanSubscriptionRow) { + const plan = getCodingPlanPrice(subscription.planId); + const providerName = plan?.providerName ?? subscription.planId; + const planName = plan?.name ?? subscription.planId; + + return { + id: subscription.id, + planId: subscription.planId, + planName, + providerName, + providerId: subscription.providerId, + routeLabel: `${providerName} via Kilo Gateway`, + hasInstalledByokKey: subscription.installedByokKeyId !== null, + status: subscription.status, + billingPeriodDays: subscription.billingPeriodDays, + currentPeriodStart: toIsoTimestamp(subscription.currentPeriodStart), + currentPeriodEnd: toIsoTimestamp(subscription.currentPeriodEnd), + creditRenewalAt: toIsoTimestamp(subscription.creditRenewalAt), + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + paymentGraceExpiresAt: toNullableIsoTimestamp(subscription.paymentGraceExpiresAt), + canceledAt: toNullableIsoTimestamp(subscription.canceledAt), + cancellationReason: subscription.cancellationReason, + createdAt: toIsoTimestamp(subscription.createdAt), + costKiloCredits: inKiloCredits(subscription.costMicrodollars), + }; +} + +export const codingPlansRouter = createTRPCRouter({ + catalog: baseProcedure.query(async ({ ctx }) => { + const [availablePlanIds, notificationIntentPlanIds] = await Promise.all([ + getAvailableCodingPlanIds(), + getCodingPlanAvailabilityIntentPlanIds(ctx.user.id), + ]); + const availablePlans = new Set(availablePlanIds); + const requestedNotifications = new Set(notificationIntentPlanIds); + + return getCodingPlanCatalog().map(plan => ({ + planId: plan.planId, + providerName: plan.providerName, + name: plan.name, + providerId: plan.providerId, + costKiloCredits: inKiloCredits(plan.costMicrodollars), + billingPeriodDays: plan.billingPeriodDays, + availabilityStatus: toAvailabilityStatus(availablePlans.has(plan.planId)), + notificationRequested: requestedNotifications.has(plan.planId), + })); + }), + + listSubscriptions: baseProcedure.query(async ({ ctx }) => { + const subscriptions = await listOwnedSubscriptions(ctx.user.id); + return subscriptions.map(toCodingPlanSubscriptionView); + }), + + getSubscriptionDetail: baseProcedure + .input(z.object({ subscriptionId: SubscriptionIdSchema })) + .query(async ({ input, ctx }) => { + const subscription = await getOwnedSubscription(ctx.user.id, input.subscriptionId); + return toCodingPlanSubscriptionView(subscription); + }), + + getBillingHistory: baseProcedure + .input(BillingHistoryInputSchema) + .output(billingHistoryResponseSchema) + .query(async ({ input, ctx }) => { + const subscription = toCodingPlanSubscriptionView( + await getOwnedSubscription(ctx.user.id, input.subscriptionId) + ); + const offset = input.cursor ? Number.parseInt(input.cursor, 10) || 0 : 0; + const transactions = await db + .select({ + id: credit_transactions.id, + date: credit_transactions.created_at, + amountMicrodollars: credit_transactions.amount_microdollars, + description: credit_transactions.description, + }) + .from(coding_plan_terms) + .innerJoin( + credit_transactions, + eq(coding_plan_terms.credit_transaction_id, credit_transactions.id) + ) + .where( + and( + eq(coding_plan_terms.subscription_id, input.subscriptionId), + eq(coding_plan_terms.user_id, ctx.user.id), + eq(credit_transactions.kilo_user_id, ctx.user.id) + ) + ) + .orderBy(desc(credit_transactions.created_at), desc(credit_transactions.id)) + .limit(26) + .offset(offset); + + return { + entries: transactions.slice(0, 25).map(transaction => ({ + kind: 'credits' as const, + id: transaction.id, + date: toIsoTimestamp(transaction.date), + amountMicrodollars: Math.abs(transaction.amountMicrodollars), + description: + transaction.description ?? + `Coding plan: ${subscription.providerName} ${subscription.planName}`, + })), + hasMore: transactions.length > 25, + cursor: transactions.length > 25 ? String(offset + 25) : null, + }; + }), + + subscribe: baseProcedure + .input( + z.object({ + planId: CodingPlanIdSchema, + idempotencyKey: z.string().min(1).max(200), + }) + ) + .mutation(async ({ input, ctx }) => { + try { + return await subscribeToCodingPlan(ctx.user.id, input.planId, input.idempotencyKey); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes('Insufficient credit balance') || + message.includes('No managed credential') || + message.includes('Remove your existing MiniMax BYOK key') + ) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message }); + } + if (message.includes('not available as a coding plan')) { + throw new TRPCError({ code: 'NOT_FOUND', message }); + } + if (message.includes('already has a live subscription')) { + throw new TRPCError({ code: 'CONFLICT', message }); + } + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message }); + } + }), + + requestAvailabilityNotification: baseProcedure + .input(z.object({ planId: CodingPlanIdSchema })) + .mutation(async ({ input, ctx }) => { + try { + return await requestCodingPlanAvailabilityNotification(ctx.user.id, input.planId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('currently available')) { + throw new TRPCError({ code: 'CONFLICT', message }); + } + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message }); + } + }), + + cancel: baseProcedure + .input(z.object({ subscriptionId: SubscriptionIdSchema })) + .mutation(async ({ input, ctx }) => { + try { + await cancelCodingPlanSubscription(ctx.user.id, input.subscriptionId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('No active subscription')) { + throw new TRPCError({ code: 'NOT_FOUND', message }); + } + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message }); + } + }), + + adminKeyInventory: adminProcedure + .input(z.object({ planId: CodingPlanIdSchema.optional() })) + .query(({ input }) => getKeyInventoryCounts(input.planId)), + + adminUploadKeys: adminProcedure + .input( + z.object({ + planId: CodingPlanIdSchema, + keys: z.array(z.string().min(1)).min(1).max(1000), + }) + ) + .mutation(({ input }) => uploadKeysToInventory(input.planId, input.keys)), + + adminTerminateSubscription: adminProcedure + .input(z.object({ subscriptionId: SubscriptionIdSchema })) + .mutation(async ({ input }) => { + try { + await terminateCodingPlanImmediately(input.subscriptionId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('No live subscription')) { + throw new TRPCError({ code: 'NOT_FOUND', message }); + } + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message }); + } + }), + + adminRevocationQueue: adminProcedure + .input( + z.object({ + planId: CodingPlanIdSchema.optional(), + status: z.enum(['revocation_pending', 'revocation_failed']).optional(), + }) + ) + .query(async ({ input }) => { + const workItems = await listManualCredentialRevocations(input); + return workItems.map(item => ({ + ...item, + revocationRequestedAt: toNullableIsoTimestamp(item.revocationRequestedAt), + revokedAt: toNullableIsoTimestamp(item.revokedAt), + updatedAt: toIsoTimestamp(item.updatedAt), + })); + }), + + adminRevealRevocationCredential: adminProcedure + .input(z.object({ inventoryKeyId: z.string().uuid() })) + .mutation(async ({ input }) => { + try { + return await revealCredentialForManualRevocation(input.inventoryKeyId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new TRPCError({ code: 'PRECONDITION_FAILED', message }); + } + }), + + adminMarkRevocationComplete: adminProcedure + .input(z.object({ inventoryKeyId: z.string().uuid() })) + .mutation(async ({ input }) => { + try { + await markCredentialManuallyRevoked(input.inventoryKeyId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new TRPCError({ code: 'PRECONDITION_FAILED', message }); + } + }), + + adminMarkRevocationFailed: adminProcedure + .input( + z.object({ inventoryKeyId: z.string().uuid(), reason: z.string().trim().min(1).max(300) }) + ) + .mutation(async ({ input }) => { + try { + await markCredentialManualRevocationFailed(input.inventoryKeyId, input.reason); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new TRPCError({ code: 'PRECONDITION_FAILED', message }); + } + }), + + adminRequeueRevocation: adminProcedure + .input(z.object({ inventoryKeyId: z.string().uuid() })) + .mutation(async ({ input }) => { + try { + await requeueManualCredentialRevocation(input.inventoryKeyId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new TRPCError({ code: 'PRECONDITION_FAILED', message }); + } + }), +}); diff --git a/apps/web/src/routers/root-router.ts b/apps/web/src/routers/root-router.ts index 4f193a0cab..2dbc10bd68 100644 --- a/apps/web/src/routers/root-router.ts +++ b/apps/web/src/routers/root-router.ts @@ -38,6 +38,7 @@ import { cloudAgentNextFeedbackRouter } from '@/routers/cloud-agent-next-feedbac import { kiloChatRouter } from '@/routers/kilo-chat-router'; import { kiloclawRouter } from '@/routers/kiloclaw-router'; import { modelsRouter } from '@/routers/models-router'; +import { codingPlansRouter } from '@/routers/coding-plans-router'; import { unifiedSessionsRouter } from '@/routers/unified-sessions-router'; import { activeSessionsRouter } from '@/routers/active-sessions-router'; import { usageAnalyticsRouter } from '@/routers/usage-analytics-router'; @@ -80,6 +81,7 @@ export const rootRouter = createTRPCRouter({ kiloChat: kiloChatRouter, kiloclaw: kiloclawRouter, models: modelsRouter, + codingPlans: codingPlansRouter, unifiedSessions: unifiedSessionsRouter, activeSessions: activeSessionsRouter, usageAnalytics: usageAnalyticsRouter, diff --git a/apps/web/vercel.json b/apps/web/vercel.json index cc4cf54be0..75858eee06 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -40,6 +40,10 @@ "path": "/api/discord/gateway", "schedule": "*/9 * * * *" }, + { + "path": "/api/cron/coding-plans-billing", + "schedule": "0 * * * *" + }, { "path": "/api/cron/db-pool-metrics", "schedule": "* * * * *" diff --git a/dev/seed/coding-plans/available-credentials.ts b/dev/seed/coding-plans/available-credentials.ts new file mode 100644 index 0000000000..76514add11 --- /dev/null +++ b/dev/seed/coding-plans/available-credentials.ts @@ -0,0 +1,156 @@ +import { createCipheriv, createHmac, randomBytes } from 'node:crypto'; + +import { coding_plan_key_inventory } from '@kilocode/db/schema'; +import type { EncryptedData } from '@kilocode/db/schema-types'; +import { and, eq, inArray } from 'drizzle-orm'; + +import { getSeedDb } from '../lib/db'; +import type { SeedResult } from '../index'; + +const PLAN_ID = 'minimax-token-plan-plus'; +const PROVIDER_ID = 'minimax'; +const KEY_PREFIX = 'dev-seed:coding-plans'; +const MAX_CREDENTIAL_COUNT = 20; + +export const usage = ' [count]'; + +function printUsage(): void { + console.log(`Usage: pnpm dev:seed coding-plans:available-credentials ${usage}`); + console.log(''); + console.log( + 'Creates encrypted placeholder inventory credentials for local subscription UI testing.' + ); + console.log( + 'These credentials bypass MiniMax validation and must not be used for provider traffic.' + ); + console.log(''); + console.log('Examples:'); + console.log(' pnpm dev:seed coding-plans:available-credentials subscription-smoke 1'); + console.log(' pnpm dev:seed coding-plans:available-credentials byok-permutations 4'); +} + +function parseCount(rawCount: string | undefined): number { + if (!rawCount) { + return 1; + } + + if (!/^\d+$/.test(rawCount)) { + throw new Error('count must be a positive integer'); + } + + const count = Number(rawCount); + if (!Number.isSafeInteger(count) || count < 1 || count > MAX_CREDENTIAL_COUNT) { + throw new Error(`count must be between 1 and ${MAX_CREDENTIAL_COUNT}`); + } + + return count; +} + +function requireScenario(value: string | undefined): string { + const scenario = value?.trim(); + if (!scenario || !/^[a-zA-Z0-9_-]{1,64}$/.test(scenario)) { + throw new Error('scenario must contain 1-64 letters, digits, underscores, or hyphens'); + } + return scenario; +} + +function requireEncryptionKey(): Buffer { + const keyBase64 = process.env.BYOK_ENCRYPTION_KEY; + if (!keyBase64) { + throw new Error('BYOK_ENCRYPTION_KEY is not configured'); + } + + const key = Buffer.from(keyBase64, 'base64'); + if (key.length !== 32) { + throw new Error('BYOK_ENCRYPTION_KEY must decode to 32 bytes'); + } + return key; +} + +function encryptCredential(plaintext: string, key: Buffer): EncryptedData { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + + return { + iv: iv.toString('base64'), + data: encrypted.toString('base64'), + authTag: cipher.getAuthTag().toString('base64'), + }; +} + +function credentialFingerprint(plaintext: string, key: Buffer): string { + return createHmac('sha256', key).update(plaintext).digest('hex'); +} + +export async function run(...args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + + const [rawScenario, rawCount, ...rest] = args; + if (rest.length > 0) { + printUsage(); + throw new Error(`Unexpected extra arguments: ${rest.join(' ')}`); + } + + const scenario = requireScenario(rawScenario); + const count = parseCount(rawCount); + const key = requireEncryptionKey(); + const credentials = Array.from({ length: count }, (_, index) => { + const plaintext = `${KEY_PREFIX}:${scenario}:${index + 1}`; + return { + fingerprint: credentialFingerprint(plaintext, key), + encrypted: encryptCredential(plaintext, key), + }; + }); + const fingerprints = credentials.map(credential => credential.fingerprint); + const db = getSeedDb(); + + const consumed = await db + .select({ status: coding_plan_key_inventory.status }) + .from(coding_plan_key_inventory) + .where(inArray(coding_plan_key_inventory.credential_fingerprint, fingerprints)); + if (consumed.some(credential => credential.status !== 'available')) { + throw new Error('This scenario has already assigned inventory. Use a new scenario name.'); + } + + const inserted = await db.transaction(async tx => { + await tx + .delete(coding_plan_key_inventory) + .where( + and( + inArray(coding_plan_key_inventory.credential_fingerprint, fingerprints), + eq(coding_plan_key_inventory.status, 'available') + ) + ); + const rows = await tx + .insert(coding_plan_key_inventory) + .values( + credentials.map( + credential => + ({ + plan_id: PLAN_ID, + provider_id: PROVIDER_ID, + encrypted_api_key: credential.encrypted, + credential_fingerprint: credential.fingerprint, + status: 'available', + }) satisfies typeof coding_plan_key_inventory.$inferInsert + ) + ) + .returning({ id: coding_plan_key_inventory.id }); + return rows.length; + }); + + console.log('This fixture supports local Coding Plan subscription and BYOK UI flows only.'); + console.log('Do not test provider traffic with placeholder credentials.'); + + return { + scenario, + planId: PLAN_ID, + providerId: PROVIDER_ID, + availableCredentials: inserted, + providerTrafficValid: false, + }; +} diff --git a/dev/seed/coding-plans/occupied-minimax-byok.ts b/dev/seed/coding-plans/occupied-minimax-byok.ts new file mode 100644 index 0000000000..aebd0aa068 --- /dev/null +++ b/dev/seed/coding-plans/occupied-minimax-byok.ts @@ -0,0 +1,105 @@ +import { createCipheriv, randomBytes } from 'node:crypto'; + +import { byok_api_keys } from '@kilocode/db/schema'; +import type { EncryptedData } from '@kilocode/db/schema-types'; +import { and, eq } from 'drizzle-orm'; + +import { getSeedDb } from '../lib/db'; +import type { SeedResult } from '../index'; + +const PROVIDER_ID = 'minimax'; +const KEY_PREFIX = 'dev-seed:coding-plans:occupied-minimax'; + +export const usage = ' [--disabled]'; + +function printUsage(): void { + console.log(`Usage: pnpm dev:seed coding-plans:occupied-minimax-byok ${usage}`); + console.log(''); + console.log('Creates an encrypted personal MiniMax BYOK key that blocks Token Plan Plus signup.'); + console.log('The placeholder key supports subscription precondition UI testing only.'); +} + +function requireEncryptionKey(): Buffer { + const keyBase64 = process.env.BYOK_ENCRYPTION_KEY; + if (!keyBase64) { + throw new Error('BYOK_ENCRYPTION_KEY is not configured'); + } + const key = Buffer.from(keyBase64, 'base64'); + if (key.length !== 32) { + throw new Error('BYOK_ENCRYPTION_KEY must decode to 32 bytes'); + } + return key; +} + +function encryptCredential(plaintext: string, key: Buffer): EncryptedData { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return { + iv: iv.toString('base64'), + data: encrypted.toString('base64'), + authTag: cipher.getAuthTag().toString('base64'), + }; +} + +function requireScenario(value: string | undefined): string { + const scenario = value?.trim(); + if (!scenario || !/^[a-zA-Z0-9_-]{1,64}$/.test(scenario)) { + throw new Error('scenario must contain 1-64 letters, digits, underscores, or hyphens'); + } + return scenario; +} + +export async function run(...args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + + const [rawUserId, rawScenario, ...options] = args; + const userId = rawUserId?.trim(); + if (!userId) { + printUsage(); + throw new Error('user-id is required'); + } + const scenario = requireScenario(rawScenario); + const unknownOptions = options.filter(option => option !== '--disabled'); + if (unknownOptions.length > 0) { + throw new Error(`Unknown arguments: ${unknownOptions.join(' ')}`); + } + const isEnabled = !options.includes('--disabled'); + const db = getSeedDb(); + const [existing] = await db + .select({ id: byok_api_keys.id }) + .from(byok_api_keys) + .where(and(eq(byok_api_keys.kilo_user_id, userId), eq(byok_api_keys.provider_id, PROVIDER_ID))) + .limit(1); + if (existing) { + throw new Error('User already has a MiniMax BYOK key. Use a new test user.'); + } + + const [inserted] = await db + .insert(byok_api_keys) + .values({ + kilo_user_id: userId, + organization_id: null, + provider_id: PROVIDER_ID, + encrypted_api_key: encryptCredential(`${KEY_PREFIX}:${scenario}`, requireEncryptionKey()), + management_source: 'user', + created_by: userId, + is_enabled: isEnabled, + } satisfies typeof byok_api_keys.$inferInsert) + .returning({ id: byok_api_keys.id }); + if (!inserted) { + throw new Error('Failed to create MiniMax BYOK key'); + } + + return { + userId, + scenario, + byokKeyId: inserted.id, + providerId: PROVIDER_ID, + enabled: isEnabled, + providerTrafficValid: false, + }; +} diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index a108087ad1..89151da53d 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -484,6 +484,43 @@ export const ImpactAdvocateRewardRedemptionState = { export type ImpactAdvocateRewardRedemptionState = (typeof ImpactAdvocateRewardRedemptionState)[keyof typeof ImpactAdvocateRewardRedemptionState]; +// --- Coding Plan enums --- + +export const BYOKManagementSource = { + User: 'user', + CodingPlan: 'coding_plan', +} as const; + +export type BYOKManagementSource = (typeof BYOKManagementSource)[keyof typeof BYOKManagementSource]; + +export const CodingPlanCredentialStatus = { + Available: 'available', + Assigned: 'assigned', + RevocationPending: 'revocation_pending', + Revoked: 'revoked', + RevocationFailed: 'revocation_failed', +} as const; + +export type CodingPlanCredentialStatus = + (typeof CodingPlanCredentialStatus)[keyof typeof CodingPlanCredentialStatus]; + +export const CodingPlanSubscriptionStatus = { + Active: 'active', + PastDue: 'past_due', + Canceled: 'canceled', +} as const; + +export type CodingPlanSubscriptionStatus = + (typeof CodingPlanSubscriptionStatus)[keyof typeof CodingPlanSubscriptionStatus]; + +export const CodingPlanTermKind = { + Activation: 'activation', + Extension: 'extension', + Renewal: 'renewal', +} as const; + +export type CodingPlanTermKind = (typeof CodingPlanTermKind)[keyof typeof CodingPlanTermKind]; + // NOTE: Do not change these action names. Use present tense for consistency. export const KiloClawAdminAuditAction = z.enum([ 'kiloclaw.volume.extend', diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index f6345360a7..5baa15290e 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -73,6 +73,10 @@ import { ImpactReferralPaymentProvider, ImpactConversionReportState, ImpactAdvocateRewardRedemptionState, + BYOKManagementSource, + CodingPlanCredentialStatus, + CodingPlanSubscriptionStatus, + CodingPlanTermKind, } from './schema-types'; import type { CustomLlmDefinition, @@ -181,6 +185,10 @@ export const SCHEMA_CHECK_ENUMS = { ImpactReferralPaymentProvider, ImpactConversionReportState, ImpactAdvocateRewardRedemptionState, + BYOKManagementSource, + CodingPlanCredentialStatus, + CodingPlanSubscriptionStatus, + CodingPlanTermKind, } as const; export type AffiliateEventPayloadJson = { @@ -347,7 +355,10 @@ export const kilocode_users = pgTable( completed_welcome_form: boolean().default(false).notNull(), linkedin_url: text(), github_url: text(), - discord_server_membership_verified_at: timestamp({ withTimezone: true, mode: 'string' }), + discord_server_membership_verified_at: timestamp({ + withTimezone: true, + mode: 'string', + }), openrouter_upstream_safety_identifier: text(), vercel_downstream_safety_identifier: text(), customer_source: text(), @@ -1451,7 +1462,10 @@ export const kilo_pass_issuances = pgTable( .notNull(), kilo_pass_subscription_id: uuid() .notNull() - .references(() => kilo_pass_subscriptions.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + .references(() => kilo_pass_subscriptions.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), issue_month: date().notNull(), source: text().notNull().$type(), stripe_invoice_id: text(), @@ -1559,12 +1573,18 @@ export const kilo_pass_issuance_items = pgTable( .notNull(), kilo_pass_issuance_id: uuid() .notNull() - .references(() => kilo_pass_issuances.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + .references(() => kilo_pass_issuances.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), kind: text().notNull().$type(), credit_transaction_id: uuid() .notNull() .unique() - .references(() => credit_transactions.id, { onDelete: 'restrict', onUpdate: 'cascade' }), + .references(() => credit_transactions.id, { + onDelete: 'restrict', + onUpdate: 'cascade', + }), amount_usd: decimal({ precision: 12, scale: 2, mode: 'number' }).notNull(), bonus_percent_applied: decimal({ precision: 6, scale: 4, mode: 'number' }), created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), @@ -1653,7 +1673,10 @@ export const kilo_pass_scheduled_changes = pgTable( .primaryKey(), kilo_user_id: text() .notNull() - .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + .references(() => kilocode_users.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), stripe_subscription_id: text() .notNull() .references(() => kilo_pass_subscriptions.stripe_subscription_id, { @@ -2368,7 +2391,10 @@ export const organizations = pgTable( total_microdollars_acquired: bigint({ mode: 'number' }) .default(sql`'0'`) .notNull(), - next_credit_expiration_at: timestamp({ withTimezone: true, mode: 'string' }), + next_credit_expiration_at: timestamp({ + withTimezone: true, + mode: 'string', + }), stripe_customer_id: text(), auto_top_up_enabled: boolean().default(false).notNull(), settings: jsonb().default({}).$type().notNull(), @@ -2697,8 +2723,12 @@ export const platform_integrations = pgTable( 'platform_integrations', { id: idPrimaryKeyColumn, - owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + owned_by_organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + owned_by_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), created_by_user_id: text(), // Platform (examples: 'github', 'gitlab', 'bitbucket', 'azure_devops') @@ -2929,7 +2959,9 @@ export const deployment_threat_detections = pgTable( deployment_id: uuid() .notNull() .references(() => deployments.id, { onDelete: 'cascade' }), - build_id: uuid().references(() => deployment_builds.id, { onDelete: 'set null' }), + build_id: uuid().references(() => deployment_builds.id, { + onDelete: 'set null', + }), threat_type: text().notNull(), // 'MALWARE' | 'SOCIAL_ENGINEERING' | 'UNWANTED_SOFTWARE' or comma-separated created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), }, @@ -3012,8 +3044,12 @@ export const agent_configs = pgTable( { id: idPrimaryKeyColumn, // Ownership: exactly one must be set (org OR user) - owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + owned_by_organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + owned_by_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), // Agent type (examples: 'code_review', 'security_scan') agent_type: text().notNull(), @@ -3081,8 +3117,12 @@ export const webhook_events = pgTable( 'webhook_events', { id: idPrimaryKeyColumn, - owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + owned_by_organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + owned_by_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), // Platform (examples: 'github', 'gitlab', 'bitbucket') platform: text().notNull(), @@ -3135,7 +3175,9 @@ export const cloud_agent_webhook_triggers = pgTable( { id: uuid('id').primaryKey().defaultRandom(), trigger_id: text('trigger_id').notNull(), - user_id: text('user_id').references(() => kilocode_users.id, { onDelete: 'cascade' }), + user_id: text('user_id').references(() => kilocode_users.id, { + onDelete: 'cascade', + }), organization_id: uuid('organization_id').references(() => organizations.id, { onDelete: 'cascade', }), @@ -3340,7 +3382,10 @@ export const modelStats = pgTable( // Performance (from Artificial Analysis) codingIndex: decimal('coding_index', { precision: 5, scale: 2 }), - speedTokensPerSec: decimal('speed_tokens_per_sec', { precision: 8, scale: 2 }), + speedTokensPerSec: decimal('speed_tokens_per_sec', { + precision: 8, + scale: 2, + }), // Technical specs (from OpenRouter) contextLength: integer('context_length'), @@ -3566,8 +3611,12 @@ export const cloud_agent_code_reviews = pgTable( { id: idPrimaryKeyColumn, // Ownership: exactly one must be set (org OR user) - owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + owned_by_organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + owned_by_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), // Platform integration (optional - for linking to integration) platform_integration_id: uuid().references(() => platform_integrations.id, { @@ -3731,7 +3780,9 @@ export const cliSessions = pgTable( onDelete: 'set null', }), cloud_agent_session_id: text().unique(), - organization_id: uuid().references(() => organizations.id, { onDelete: 'set null' }), + organization_id: uuid().references(() => organizations.id, { + onDelete: 'set null', + }), last_mode: text(), last_model: text(), version: integer().notNull().default(0), @@ -3759,7 +3810,9 @@ export const sharedCliSessions = pgTable( .default(sql`pg_catalog.gen_random_uuid()`) .primaryKey() .notNull(), - session_id: uuid().references(() => cliSessions.session_id, { onDelete: 'set null' }), + session_id: uuid().references(() => cliSessions.session_id, { + onDelete: 'set null', + }), kilo_user_id: text() .notNull() .references(() => kilocode_users.id, { onDelete: 'restrict' }), @@ -3794,7 +3847,9 @@ export const cli_sessions_v2 = pgTable( title: text(), public_id: uuid(), parent_session_id: text(), - organization_id: uuid().references(() => organizations.id, { onDelete: 'set null' }), + organization_id: uuid().references(() => organizations.id, { + onDelete: 'set null', + }), cloud_agent_session_id: text(), created_on_platform: text().notNull().default('unknown'), git_url: text(), @@ -3913,7 +3968,9 @@ export const device_auth_requests = pgTable( .primaryKey() .notNull(), code: text().notNull(), - kilo_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + kilo_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), status: text() .$type<'pending' | 'approved' | 'denied' | 'expired'>() .notNull() @@ -3953,7 +4010,9 @@ export const app_builder_projects = pgTable( title: text().notNull(), model_id: text().notNull(), template: text(), // nullable - null means default template (nextjs-starter) - deployment_id: uuid().references(() => deployments.id, { onDelete: 'set null' }), + deployment_id: uuid().references(() => deployments.id, { + onDelete: 'set null', + }), last_message_at: timestamp({ withTimezone: true, mode: 'string' }), // Git platform migration fields (GitHub, GitLab, etc.) git_repo_full_name: text(), // "owner/repo" after migration @@ -4030,7 +4089,9 @@ export const app_reported_messages = pgTable('app_reported_messages', { signature: jsonb().$type>().notNull(), message: jsonb().$type>().notNull(), created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), - cli_session_id: uuid().references(() => cliSessions.session_id, { onDelete: 'set null' }), + cli_session_id: uuid().references(() => cliSessions.session_id, { + onDelete: 'set null', + }), mode: text(), model: text(), }); @@ -4041,10 +4102,15 @@ export const byok_api_keys = pgTable( 'byok_api_keys', { id: idPrimaryKeyColumn, - organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - kilo_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + kilo_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), provider_id: text().notNull(), encrypted_api_key: jsonb().$type().notNull(), + management_source: text().$type().notNull().default('user'), is_enabled: boolean().default(true).notNull(), created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), updated_at: timestamp({ withTimezone: true, mode: 'string' }) @@ -4061,6 +4127,11 @@ export const byok_api_keys = pgTable( index('IDX_byok_api_keys_organization_id').on(table.organization_id), index('IDX_byok_api_keys_kilo_user_id').on(table.kilo_user_id), index('IDX_byok_api_keys_provider_id').on(table.provider_id), + enumCheck( + 'byok_api_keys_management_source_check', + table.management_source, + BYOKManagementSource + ), // Owner check constraint (exactly one must be set) check( 'byok_api_keys_owner_check', @@ -4081,8 +4152,12 @@ export const security_findings = pgTable( id: idPrimaryKeyColumn, // Ownership (same pattern as cloud_agent_code_reviews) - owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + owned_by_organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + owned_by_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), // Platform integration reference platform_integration_id: uuid().references(() => platform_integrations.id, { @@ -4196,8 +4271,12 @@ export const security_analysis_queue = pgTable( finding_id: uuid() .notNull() .references(() => security_findings.id, { onDelete: 'cascade' }), - owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + owned_by_organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + owned_by_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), queue_status: text().notNull(), severity_rank: smallint().notNull(), queued_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), @@ -4312,13 +4391,20 @@ export const security_analysis_owner_state = pgTable( 'security_analysis_owner_state', { id: idPrimaryKeyColumn, - owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + owned_by_organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + owned_by_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), auto_analysis_enabled_at: timestamp({ withTimezone: true, mode: 'string' }), blocked_until: timestamp({ withTimezone: true, mode: 'string' }), block_reason: text(), consecutive_actor_resolution_failures: integer().notNull().default(0), - last_actor_resolution_failure_at: timestamp({ withTimezone: true, mode: 'string' }), + last_actor_resolution_failure_at: timestamp({ + withTimezone: true, + mode: 'string', + }), created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), updated_at: timestamp({ withTimezone: true, mode: 'string' }) .defaultNow() @@ -4354,8 +4440,12 @@ export const security_audit_log = pgTable( { id: idPrimaryKeyColumn, // XOR ownership: exactly one of owned_by_organization_id or owned_by_user_id must be set. - owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + owned_by_organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + owned_by_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), // actor_id is text to match kilocode_users.id; nullable for system-initiated actions actor_id: text(), actor_email: text(), @@ -4397,8 +4487,12 @@ export const slack_bot_requests = pgTable( id: idPrimaryKeyColumn, // Ownership (from the platform_integration) - owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + owned_by_organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + owned_by_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), // Platform integration reference platform_integration_id: uuid().references(() => platform_integrations.id, { @@ -4466,8 +4560,12 @@ export const auto_triage_tickets = pgTable( id: idPrimaryKeyColumn, // Ownership (exactly one must be set) - owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + owned_by_organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + owned_by_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), // Platform integration platform_integration_id: uuid().references(() => platform_integrations.id, { @@ -4596,8 +4694,12 @@ export const auto_fix_tickets = pgTable( id: idPrimaryKeyColumn, // Ownership (exactly one must be set) - owned_by_organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), - owned_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'cascade' }), + owned_by_organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), + owned_by_user_id: text().references(() => kilocode_users.id, { + onDelete: 'cascade', + }), // Platform integration platform_integration_id: uuid().references(() => platform_integrations.id, { @@ -4605,7 +4707,9 @@ export const auto_fix_tickets = pgTable( }), // Link to triage ticket (optional) - triage_ticket_id: uuid().references(() => auto_triage_tickets.id, { onDelete: 'set null' }), + triage_ticket_id: uuid().references(() => auto_triage_tickets.id, { + onDelete: 'set null', + }), // GitHub metadata platform: text().notNull().default('github'), @@ -4638,7 +4742,9 @@ export const auto_fix_tickets = pgTable( // Cloud Agent session session_id: text(), // Cloud agent session ID (agent_xxx) - cli_session_id: uuid().references(() => cliSessions.session_id, { onDelete: 'set null' }), + cli_session_id: uuid().references(() => cliSessions.session_id, { + onDelete: 'set null', + }), // PR information pr_number: integer(), @@ -5574,7 +5680,9 @@ export const kiloclaw_version_pins = pgTable('kiloclaw_version_pins', { .unique(), image_tag: text() .notNull() - .references(() => kiloclaw_image_catalog.image_tag, { onDelete: 'restrict' }), + .references(() => kiloclaw_image_catalog.image_tag, { + onDelete: 'restrict', + }), pinned_by: text() .notNull() .references(() => kilocode_users.id), @@ -6136,7 +6244,9 @@ export const bot_requests = pgTable( created_by: text() .notNull() .references(() => kilocode_users.id, { onDelete: 'cascade' }), - organization_id: uuid().references(() => organizations.id, { onDelete: 'cascade' }), + organization_id: uuid().references(() => organizations.id, { + onDelete: 'cascade', + }), platform_integration_id: uuid().references(() => platform_integrations.id, { onDelete: 'set null', @@ -6284,6 +6394,162 @@ export const kiloclaw_cli_runs = pgTable( export type KiloClawCliRun = typeof kiloclaw_cli_runs.$inferSelect; export type NewKiloClawCliRun = typeof kiloclaw_cli_runs.$inferInsert; +// ============================================================================= +// Coding Plans +// ============================================================================= + +export const coding_plan_key_inventory = pgTable( + 'coding_plan_key_inventory', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + plan_id: text().notNull(), + provider_id: text().notNull(), + encrypted_api_key: jsonb().$type(), + credential_fingerprint: text().notNull(), + status: text().$type().notNull().default('available'), + assigned_to_user_id: text().references(() => kilocode_users.id, { + onDelete: 'set null', + }), + assigned_at: timestamp({ withTimezone: true, mode: 'string' }), + revocation_requested_at: timestamp({ withTimezone: true, mode: 'string' }), + revoked_at: timestamp({ withTimezone: true, mode: 'string' }), + revocation_attempt_count: integer().notNull().default(0), + last_revocation_error: text(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp({ withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }, + table => [ + uniqueIndex('UQ_coding_plan_key_inv_fingerprint').on(table.credential_fingerprint), + index('IDX_coding_plan_key_inv_plan_status').on(table.plan_id, table.status), + index('IDX_coding_plan_key_inv_available') + .on(table.plan_id) + .where(sql`${table.status} = 'available'`), + enumCheck('coding_plan_key_inventory_status_check', table.status, CodingPlanCredentialStatus), + ] +); + +export type CodingPlanKeyInventory = typeof coding_plan_key_inventory.$inferSelect; + +export const coding_plan_subscriptions = pgTable( + 'coding_plan_subscriptions', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade' }), + plan_id: text().notNull(), + provider_id: text().notNull(), + key_inventory_id: uuid().references(() => coding_plan_key_inventory.id, { + onDelete: 'set null', + }), + installed_byok_key_id: uuid().references(() => byok_api_keys.id, { + onDelete: 'set null', + }), + status: text().notNull().$type(), + cost_microdollars: bigint({ mode: 'number' }).notNull(), + billing_period_days: integer().notNull(), + current_period_start: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + current_period_end: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + credit_renewal_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + cancel_at_period_end: boolean().notNull().default(false), + past_due_started_at: timestamp({ withTimezone: true, mode: 'string' }), + payment_grace_expires_at: timestamp({ withTimezone: true, mode: 'string' }), + auto_top_up_attempted_for_due: timestamp({ withTimezone: true, mode: 'string' }), + canceled_at: timestamp({ withTimezone: true, mode: 'string' }), + cancellation_reason: text(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp({ withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }, + table => [ + uniqueIndex('UQ_coding_plan_sub_live_user_plan') + .on(table.user_id, table.plan_id) + .where(sql`${table.status} IN ('active', 'past_due')`), + index('IDX_coding_plan_sub_status').on(table.status), + index('IDX_coding_plan_sub_renewal').on(table.credit_renewal_at), + index('IDX_coding_plan_sub_inventory').on(table.key_inventory_id), + enumCheck('coding_plan_subscriptions_status_check', table.status, CodingPlanSubscriptionStatus), + check( + 'coding_plan_subscriptions_live_access_check', + sql`${table.status} = 'canceled' OR ${table.key_inventory_id} IS NOT NULL` + ), + ] +); + +export type CodingPlanSubscription = typeof coding_plan_subscriptions.$inferSelect; +export type NewCodingPlanSubscription = typeof coding_plan_subscriptions.$inferInsert; + +export const coding_plan_terms = pgTable( + 'coding_plan_terms', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + subscription_id: uuid() + .notNull() + .references(() => coding_plan_subscriptions.id, { onDelete: 'cascade' }), + user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade' }), + plan_id: text().notNull(), + kind: text().$type().notNull(), + idempotency_key: text().notNull(), + period_start: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + period_end: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + cost_microdollars: bigint({ mode: 'number' }).notNull(), + credit_transaction_id: uuid() + .notNull() + .references(() => credit_transactions.id), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + uniqueIndex('UQ_coding_plan_terms_request').on( + table.user_id, + table.plan_id, + table.idempotency_key + ), + index('IDX_coding_plan_terms_subscription').on(table.subscription_id), + enumCheck('coding_plan_terms_kind_check', table.kind, CodingPlanTermKind), + ] +); + +export type CodingPlanTerm = typeof coding_plan_terms.$inferSelect; +export type NewCodingPlanTerm = typeof coding_plan_terms.$inferInsert; + +export const coding_plan_availability_intents = pgTable( + 'coding_plan_availability_intents', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade' }), + plan_id: text().notNull(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + uniqueIndex('UQ_coding_plan_availability_intents_user_plan').on(table.user_id, table.plan_id), + index('IDX_coding_plan_availability_intents_plan').on(table.plan_id), + ] +); + +export type CodingPlanAvailabilityIntent = typeof coding_plan_availability_intents.$inferSelect; +export type NewCodingPlanAvailabilityIntent = typeof coding_plan_availability_intents.$inferInsert; + // ─── Push Notification Tokens ──────────────────────────────────────── export const user_push_tokens = pgTable( From 425340a0ca0a0af2638f572a667b08aee107f4d1 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 28 May 2026 14:59:03 +0200 Subject: [PATCH 3/8] feat(subscriptions): add Coding Plans management surfaces --- apps/web/public/logos/minimax.svg | 1 + .../(app)/components/PersonalAppSidebar.tsx | 44 +- .../coding-plans/[subscriptionId]/page.tsx | 10 +- .../CodingPlansOperationsContent.tsx | 614 ++++++++++++++++++ apps/web/src/app/admin/coding-plans/page.tsx | 17 + .../src/app/admin/components/AppSidebar.tsx | 5 + .../organizations/byok/BYOKKeysManager.tsx | 224 ++++++- .../kilo-pass/KiloPassSubscribeCard.tsx | 138 ++-- .../profile/kilo-pass/KiloPassTierCard.tsx | 48 +- .../subscriptions/AvailableProductCard.tsx | 82 ++- .../subscriptions/BillingHistoryTable.tsx | 46 +- .../subscriptions/DetailPageHeader.tsx | 15 +- .../subscriptions/PersonalSubscriptions.tsx | 116 +++- .../subscriptions/SubscriptionCard.tsx | 10 +- .../subscriptions/SubscriptionGroup.tsx | 30 +- .../subscriptions/SubscriptionStatusBadge.tsx | 2 +- .../subscriptions/TerminalToggle.tsx | 9 +- .../coding-plans/CodingPlanDetail.tsx | 261 +++++++- .../coding-plans/CodingPlansGroup.tsx | 349 +++++++++- .../coding-plans/MiniMaxPlanIcon.tsx | 8 + .../components/subscriptions/helpers.test.ts | 62 ++ .../src/components/subscriptions/helpers.ts | 87 +++ .../kilo-pass/KiloPassDetail.tsx | 40 +- .../subscriptions/kilo-pass/KiloPassGroup.tsx | 12 +- .../subscriptions/kiloclaw/KiloClawDetail.tsx | 10 +- .../subscriptions/kiloclaw/KiloClawGroup.tsx | 8 +- .../kiloclaw/KiloClawSubscribeCard.tsx | 48 +- apps/web/src/components/ui/alert.tsx | 2 +- 28 files changed, 2007 insertions(+), 291 deletions(-) create mode 100644 apps/web/public/logos/minimax.svg create mode 100644 apps/web/src/app/admin/coding-plans/CodingPlansOperationsContent.tsx create mode 100644 apps/web/src/app/admin/coding-plans/page.tsx create mode 100644 apps/web/src/components/subscriptions/coding-plans/MiniMaxPlanIcon.tsx diff --git a/apps/web/public/logos/minimax.svg b/apps/web/public/logos/minimax.svg new file mode 100644 index 0000000000..cca722983a --- /dev/null +++ b/apps/web/public/logos/minimax.svg @@ -0,0 +1 @@ +MiniMax \ No newline at end of file diff --git a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx index 8d266db392..6cafe5b52e 100644 --- a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx @@ -3,7 +3,7 @@ import { Sidebar, SidebarContent, SidebarHeader } from '@/components/ui/sidebar'; import { useUser } from '@/hooks/useUser'; import { useKiloClawStatus } from '@/hooks/useKiloClaw'; -import { useEffect, useMemo, useState } from 'react'; +import { useState } from 'react'; import { Code, Coins, @@ -197,6 +197,7 @@ export default function PersonalAppSidebar(props: React.ComponentProps = [ { @@ -262,13 +263,16 @@ export default function PersonalAppSidebar(props: React.ComponentProps( - isKiloClawPath && hasKiloClawInstance ? 'kiloClaw' : 'main' - ); - - useEffect(() => { - setSidebarMenu(isKiloClawPath && hasKiloClawInstance ? 'kiloClaw' : 'main'); - }, [hasKiloClawInstance, isKiloClawPath]); + const [sidebarMenuOverride, setSidebarMenuOverride] = useState<{ + pathname: string; + menu: 'main' | 'kiloClaw'; + } | null>(null); + const sidebarMenu = + hasKiloClawInstance && sidebarMenuOverride?.pathname === pathname + ? sidebarMenuOverride.menu + : isKiloClawPath && hasKiloClawInstance + ? 'kiloClaw' + : 'main'; const kiloClawEntryItems: Array<{ title: string; @@ -282,7 +286,7 @@ export default function PersonalAppSidebar(props: React.ComponentProps setSidebarMenu('kiloClaw'), + onClick: () => setSidebarMenuOverride({ pathname, menu: 'kiloClaw' }), isActive: isKiloClawPath, suffixIcon: ChevronRight, }, @@ -304,22 +308,18 @@ export default function PersonalAppSidebar(props: React.ComponentProps setSidebarMenu('main'), + onClick: () => setSidebarMenuOverride({ pathname, menu: 'main' }), }, ]; - const allUrls = useMemo( - () => - [ - kiloClawBaseUrl, - ...dashboardItems, - ...kiloClawItems, - ...cloudItems, - ...accountItems, - ...startItems, - ].map(i => (typeof i === 'string' ? i : i.url)), - [kiloClawBaseUrl, dashboardItems, kiloClawItems, cloudItems, accountItems, startItems] - ); + const allUrls = [ + kiloClawBaseUrl, + ...dashboardItems, + ...kiloClawItems, + ...cloudItems, + ...accountItems, + ...startItems, + ].map(i => (typeof i === 'string' ? i : i.url)); return ( diff --git a/apps/web/src/app/(app)/subscriptions/coding-plans/[subscriptionId]/page.tsx b/apps/web/src/app/(app)/subscriptions/coding-plans/[subscriptionId]/page.tsx index 4b9c89c272..83bd228d90 100644 --- a/apps/web/src/app/(app)/subscriptions/coding-plans/[subscriptionId]/page.tsx +++ b/apps/web/src/app/(app)/subscriptions/coding-plans/[subscriptionId]/page.tsx @@ -1,10 +1,16 @@ import { PageContainer } from '@/components/layouts/PageContainer'; import { CodingPlanDetail } from '@/components/subscriptions/coding-plans/CodingPlanDetail'; -export default function CodingPlanSubscriptionPage() { +export default async function CodingPlanSubscriptionPage({ + params, +}: { + params: Promise<{ subscriptionId: string }>; +}) { + const { subscriptionId } = await params; + return ( - + ); } diff --git a/apps/web/src/app/admin/coding-plans/CodingPlansOperationsContent.tsx b/apps/web/src/app/admin/coding-plans/CodingPlansOperationsContent.tsx new file mode 100644 index 0000000000..386b0e2228 --- /dev/null +++ b/apps/web/src/app/admin/coding-plans/CodingPlansOperationsContent.tsx @@ -0,0 +1,614 @@ +'use client'; + +import { useReducer } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { ExternalLink, RefreshCw, ShieldAlert, Upload } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { useTRPC } from '@/lib/trpc/utils'; + +const PLAN_ID = 'minimax-token-plan-plus'; + +type OperationsState = { + keysText: string; + revealInventoryId: string | null; + revealedCredential: string | null; + completeInventoryId: string | null; + failureInventoryId: string | null; + failureReason: string; +}; + +const INITIAL_OPERATIONS_STATE: OperationsState = { + keysText: '', + revealInventoryId: null, + revealedCredential: null, + completeInventoryId: null, + failureInventoryId: null, + failureReason: '', +}; + +function updateOperationsState(state: OperationsState, update: Partial) { + return { ...state, ...update }; +} + +export function CodingPlansOperationsContent() { + const trpc = useTRPC(); + const [state, updateState] = useReducer(updateOperationsState, INITIAL_OPERATIONS_STATE); + const { + keysText, + revealInventoryId, + revealedCredential, + completeInventoryId, + failureInventoryId, + failureReason, + } = state; + const setKeysText = (keysText: string) => updateState({ keysText }); + const setRevealInventoryId = (revealInventoryId: string | null) => + updateState({ revealInventoryId }); + const setRevealedCredential = (revealedCredential: string | null) => + updateState({ revealedCredential }); + const setCompleteInventoryId = (completeInventoryId: string | null) => + updateState({ completeInventoryId }); + const setFailureInventoryId = (failureInventoryId: string | null) => + updateState({ failureInventoryId }); + const setFailureReason = (failureReason: string) => updateState({ failureReason }); + + const countsQuery = useQuery(trpc.codingPlans.adminKeyInventory.queryOptions({})); + const queueQuery = useQuery(trpc.codingPlans.adminRevocationQueue.queryOptions({})); + + const refreshOperations = async () => { + await Promise.all([countsQuery.refetch(), queueQuery.refetch()]); + }; + + const uploadMutation = useMutation( + trpc.codingPlans.adminUploadKeys.mutationOptions({ + onSuccess: async result => { + setKeysText(''); + toast.success( + `${result.inserted} validated credential${result.inserted === 1 ? '' : 's'} added to inventory.` + ); + await refreshOperations(); + }, + onError: error => toast.error(error.message || 'Credential validation or upload failed.'), + }) + ); + const revealMutation = useMutation( + trpc.codingPlans.adminRevealRevocationCredential.mutationOptions({ + onSuccess: result => setRevealedCredential(result.apiKey), + onError: error => toast.error(error.message || 'Credential reveal failed.'), + }) + ); + const completeMutation = useMutation( + trpc.codingPlans.adminMarkRevocationComplete.mutationOptions({ + onSuccess: async () => { + closeRevealDialog(); + setCompleteInventoryId(null); + toast.success('Credential marked revoked. Stored secret material cleared.'); + await refreshOperations(); + }, + onError: error => toast.error(error.message || 'Unable to mark credential revoked.'), + }) + ); + const failureMutation = useMutation( + trpc.codingPlans.adminMarkRevocationFailed.mutationOptions({ + onSuccess: async () => { + closeRevealDialog(); + setFailureInventoryId(null); + setFailureReason(''); + toast.success('Revocation failure recorded for retry.'); + await refreshOperations(); + }, + onError: error => toast.error(error.message || 'Unable to record revocation failure.'), + }) + ); + const requeueMutation = useMutation( + trpc.codingPlans.adminRequeueRevocation.mutationOptions({ + onSuccess: async () => { + toast.success('Credential requeued for manual revocation.'); + await refreshOperations(); + }, + onError: error => toast.error(error.message || 'Unable to requeue credential.'), + }) + ); + + const submittedKeys = keysText + .split('\n') + .map(key => key.trim()) + .filter(key => key.length > 0); + const workItems = queueQuery.data ?? []; + const inventoryCounts = countsQuery.data ?? []; + const totalCredentialCount = inventoryCounts.reduce((total, item) => total + item.count, 0); + const countCredentialsByStatus = (status: string) => + inventoryCounts.reduce((total, item) => total + (item.status === status ? item.count : 0), 0); + const inventorySummary = [ + { + label: 'Total credentials in system', + count: totalCredentialCount, + detail: 'All inventory states', + }, + { + label: 'Available credentials in system', + count: countCredentialsByStatus('available'), + detail: 'Ready for assignment', + }, + { + label: 'Revoked credentials', + count: countCredentialsByStatus('revoked'), + detail: 'Confirmed complete', + }, + { + label: 'Pending revocation credentials', + count: countCredentialsByStatus('revocation_pending'), + detail: 'Awaiting manual action', + }, + ]; + + function closeRevealDialog() { + setRevealInventoryId(null); + setRevealedCredential(null); + revealMutation.reset(); + } + + return ( +
+
+
+

Coding plans operations

+

+ Manage validated Token Plan Plus inventory and manual MiniMax credential revocation. +

+
+ +
+ + + + void refreshOperations()} + onReveal={setRevealInventoryId} + onComplete={setCompleteInventoryId} + onFailure={setFailureInventoryId} + onRequeue={inventoryKeyId => requeueMutation.mutate({ inventoryKeyId })} + onKeysTextChange={setKeysText} + onUpload={() => uploadMutation.mutate({ planId: PLAN_ID, keys: submittedKeys })} + /> + + revealMutation.mutate({ inventoryKeyId })} + onCloseComplete={() => setCompleteInventoryId(null)} + onComplete={inventoryKeyId => completeMutation.mutate({ inventoryKeyId })} + onCloseFailure={() => setFailureInventoryId(null)} + onFailureReasonChange={setFailureReason} + onFailure={(inventoryKeyId, reason) => failureMutation.mutate({ inventoryKeyId, reason })} + /> +
+ ); +} + +type InventorySummaryItem = { + label: string; + count: number; + detail: string; +}; + +type RevocationWorkItem = { + inventoryKeyId: string; + planId: string; + status: string; + revocationRequestedAt: string | null; + revocationAttemptCount: number; + lastRevocationError: string | null; +}; + +function InventorySummaryCards({ + items, + isLoading, + isError, +}: { + items: InventorySummaryItem[]; + isLoading: boolean; + isError: boolean; +}) { + return ( +
+ {items.map(summary => ( + + +

{summary.label}

+ {isLoading ? ( +
+
+ ))} +
+ ); +} + +function OperationsTabs({ + workItems, + queueLoading, + queueError, + keysText, + submittedKeys, + uploadPending, + requeuePending, + onRefresh, + onReveal, + onComplete, + onFailure, + onRequeue, + onKeysTextChange, + onUpload, +}: { + workItems: RevocationWorkItem[]; + queueLoading: boolean; + queueError: boolean; + keysText: string; + submittedKeys: string[]; + uploadPending: boolean; + requeuePending: boolean; + onRefresh: () => void; + onReveal: (inventoryKeyId: string) => void; + onComplete: (inventoryKeyId: string) => void; + onFailure: (inventoryKeyId: string) => void; + onRequeue: (inventoryKeyId: string) => void; + onKeysTextChange: (keysText: string) => void; + onUpload: () => void; +}) { + return ( + + + Manual revocation queue + Upload validated inventory + + + + + +
+ Manual revocation queue + + Pending and failed issued credentials requiring action in MiniMax admin tooling. + +
+ +
+ +
+ + + + Inventory item + Status + Requested + Attempts + Latest failure + Actions + + + + {queueError ? ( + + + Unable to load manual revocation work. Refresh to retry. + + + ) : workItems.length === 0 ? ( + + + {queueLoading ? 'Loading manual work...' : 'No revocation work pending.'} + + + ) : ( + workItems.map(item => ( + + +
{item.inventoryKeyId}
+
{item.planId}
+
+ + + {formatStatus(item.status)} + + + + {formatTimestamp(item.revocationRequestedAt)} + + + {item.revocationAttemptCount} + + + {item.lastRevocationError ?? 'None'} + + +
+ + + + {item.status === 'revocation_failed' ? ( + + ) : null} +
+
+
+ )) + )} +
+
+
+
+
+
+ + + + + Upload validated inventory + + Enter one MiniMax credential per line. Each credential is tested through ordinary + MiniMax routing before encrypted storage as available inventory. + + + +
+ +