From 309609f76b110342c4b36ab47d4c5133a0574aeb Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Sat, 18 Jul 2026 14:39:48 +0000 Subject: [PATCH 1/3] Add self-service trials and signup alerts --- .env.example | 2 + ARCHITECTURE.md | 11 +- fly.toml | 5 +- src/app/docs/page.tsx | 9 +- src/app/docs/quickstart/page.tsx | 11 +- src/app/docs/self-hosted/page.tsx | 6 +- src/app/install/page.tsx | 7 +- src/app/orgs/[slug]/actions.ts | 172 ++++++++++++++++++-------- src/app/orgs/[slug]/billing/page.tsx | 27 ++-- src/app/orgs/[slug]/settings-form.tsx | 41 ++++-- src/app/orgs/[slug]/settings/page.tsx | 15 ++- src/app/pricing/page.tsx | 21 ++-- src/app/privacy/page.tsx | 18 ++- src/app/terms/page.tsx | 4 +- src/components/forge-install-tabs.tsx | 9 +- src/lib/email-verification.ts | 22 +++- src/lib/env.ts | 29 ++++- src/lib/github/installation-sync.ts | 61 ++++++--- src/lib/github/webhook-handler.ts | 8 +- src/lib/queue.ts | 12 ++ src/lib/self-service-trial.ts | 72 +++++++++++ src/worker/operator-alert.ts | 77 ++++++++++++ src/worker/runner.ts | 8 ++ tests/billing-page-auth.test.ts | 2 +- tests/env-validation.test.ts | 15 +++ tests/operator-alert-job.test.ts | 62 ++++++++++ tests/org-settings-actions.test.ts | 91 +++++++++++++- tests/pricing-policy.test.ts | 23 ++-- tests/webhook-handlers.test.ts | 119 ++++++++++++++++++ tests/worker-runner.test.ts | 24 ++++ 30 files changed, 823 insertions(+), 160 deletions(-) create mode 100644 src/lib/self-service-trial.ts create mode 100644 src/worker/operator-alert.ts create mode 100644 tests/operator-alert-job.test.ts diff --git a/.env.example b/.env.example index 11ca126d..e8534160 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,8 @@ POSTIL_LLM_TOTAL_TIMEOUT_SECS=540 BREVO_API_KEY= POSTIL_EMAIL_FROM_EMAIL=reviews@mail.postil.dev POSTIL_EMAIL_FROM_NAME=Postil +# Verified operator inbox for one-time trial signup alerts. +POSTIL_OPERATOR_ALERT_EMAIL= # GitHub Enterprise Server: point both the worker and the spawned CLI at your # instance's API (one variable covers both). Leave unset for github.com. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 962523d6..6fb6818b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -95,6 +95,13 @@ Private-repository product access is organization-scoped and fail-closed. `organization_entitlements` records hosted or BYOK subscription mode, lifecycle state, trial and past-due grace boundaries, operator promotions, verified billing contacts, and the current-period included usage plus overage hard cap. +The first active GitHub App installation for an owner atomically inserts one +30-day trial and, when operator email is configured, a durable alert job. The +trial uses hosted inference when hosted service is enabled and BYOK otherwise. +The organization GitHub ID is the trial identity, and the entitlement survives +uninstall, so reinstalling cannot restart the trial. Alert delivery uses a +provider idempotency key and contains account and installation metadata without +repository content. `src/lib/private-repository-entitlement.ts` is the single decision point used by webhook intake and workers. The webhook stores delivery and repository metadata, then skips review/respond queue, check, and conversational comment side effects @@ -131,8 +138,8 @@ Both review envelopes and respond receipts carry `usageAccountingComplete`. Missing or false completeness consumes at least the full reservation while known per-model token and price rows remain available as analytics; an unattributed adjustment event makes committed billing equal the conservative charge. -Provider credentials do not grant product access. Operators apply the -complete entitlement state idempotently through +Provider credentials do not grant product access. Operators can apply the +complete post-trial entitlement state idempotently through `scripts/set-org-entitlement.ts`; the billing page reports the stored state and lets organization administrators set the hosted overage hard cap. BYOK billing copy directs administrators to provider-side budgets because Postil cannot diff --git a/fly.toml b/fly.toml index ad9c7c8a..73c76063 100644 --- a/fly.toml +++ b/fly.toml @@ -58,9 +58,10 @@ WORKER_IDLE_POLL_MAX_MS = "900000" WORKER_WATCHDOG_INTERVAL_MS = "60000" POSTHOG_SERVER_CAPTURE = "1" POSTHOG_CLIENT_CAPTURE = "1" -POSTHOG_ERROR_CAPTURE = "0" -POSTHOG_LOG_CAPTURE = "0" +POSTHOG_ERROR_CAPTURE = "1" +POSTHOG_LOG_CAPTURE = "1" NEXT_PUBLIC_POSTHOG_HOST = "https://eu.i.posthog.com" +POSTIL_OPERATOR_ALERT_EMAIL = "hello@postil.dev" [http_service] processes = ["web"] diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index ad13e928..7d01c50b 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -17,7 +17,7 @@ const CARDS = [ { href: "/docs/quickstart", title: "Quickstart", - body: "Install the GitHub App, connect your model provider, then add local and CI workflows if you need them.", + body: "Install the GitHub App, connect your provider, start a 30-day free trial, then add local and CI workflows if needed.", }, { href: "/docs/coding-agents", @@ -81,9 +81,10 @@ export default function DocsIndexPage() {

Documentation

- Install the GitHub App and connect your model provider to review new - pull requests. These pages also cover the open-source CLI, CI merge gating, - configuration, and running the whole stack on your own hardware. + Install the GitHub App, connect your provider, and start a 30-day free + trial to review new pull requests. These pages also cover the open-source + CLI, CI merge gating, configuration, and running the whole stack on your + own hardware.

{CARDS.map((card) => ( diff --git a/src/app/docs/quickstart/page.tsx b/src/app/docs/quickstart/page.tsx index eb2f6cf8..36a88e94 100644 --- a/src/app/docs/quickstart/page.tsx +++ b/src/app/docs/quickstart/page.tsx @@ -20,16 +20,17 @@ export default function QuickstartPage() {

Quickstart

- Start with the GitHub App and your model provider. It reviews new - non-draft pull requests after provider setup. Add the CLI and GitHub + Start with the GitHub App, your model provider, and a 30-day free trial. It + reviews new non-draft pull requests after setup. Add the CLI and GitHub Action for local reviews and merge blocking.

1. GitHub App with BYOK

- Install the App, select all repositories or a chosen set, then add your - provider credentials in organization settings. Postil reviews new - non-draft pull requests in those repositories after setup. + Install the App, select all repositories or a chosen set, then configure + your provider API, model, and key in organization settings. Your free + 30-day trial starts automatically. Postil reviews new non-draft pull requests + in those repositories after setup.

Draft pull requests are skipped until marked ready. Existing open pull diff --git a/src/app/docs/self-hosted/page.tsx b/src/app/docs/self-hosted/page.tsx index fe41e076..12f92318 100644 --- a/src/app/docs/self-hosted/page.tsx +++ b/src/app/docs/self-hosted/page.tsx @@ -155,11 +155,13 @@ docker compose exec web bun run db:migrate`} to run.

  • - BREVO_API_KEY enables billing-contact verification email. + BREVO_API_KEY enables transactional email. The sender defaults to{" "} reviews@mail.postil.dev and can be changed with{" "} POSTIL_EMAIL_FROM_EMAIL and{" "} - POSTIL_EMAIL_FROM_NAME. + POSTIL_EMAIL_FROM_NAME. Set{" "} + POSTIL_OPERATOR_ALERT_EMAIL to a verified operator inbox + for one-time trial signup alerts.
  • diff --git a/src/app/install/page.tsx b/src/app/install/page.tsx index 4bc86c6c..66188d27 100644 --- a/src/app/install/page.tsx +++ b/src/app/install/page.tsx @@ -9,7 +9,7 @@ import { githubAppInstallUrl } from "@/lib/github-app"; export const metadata: Metadata = { title: "Install", description: - "Install Postil for GitHub with your model provider, or run the CLI in GitLab, Bitbucket, and Azure DevOps CI.", + "Install Postil for GitHub with a 30-day BYOK trial, or run the CLI in GitLab, Bitbucket, and Azure DevOps CI.", alternates: { canonical: "/install" }, openGraph: { title: "Install Postil", @@ -28,8 +28,9 @@ export default function InstallPage() { Choose your forge. Start reviewing pull requests.

    - On GitHub, install the App and connect your model provider. GitLab, - Bitbucket, and Azure DevOps run the Postil CLI inside their own CI. + On GitHub, install the App, connect your model provider, and start a + 30-day free trial. GitLab, Bitbucket, and Azure DevOps run the Postil CLI + inside their own CI.

    diff --git a/src/app/orgs/[slug]/actions.ts b/src/app/orgs/[slug]/actions.ts index 145ab2df..c513d415 100644 --- a/src/app/orgs/[slug]/actions.ts +++ b/src/app/orgs/[slug]/actions.ts @@ -2,7 +2,7 @@ import { revalidatePath } from "next/cache"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, gt, sql } from "drizzle-orm"; import { validateApiBase } from "@/lib/api-base"; import { centsToMicros } from "@/lib/billing-credits"; @@ -19,6 +19,7 @@ import { } from "@/lib/byok-provider"; import { getSealingKey, seal, unseal } from "@/lib/crypto/seal"; import { getDb, schema } from "@/lib/db"; +import { hostedInferenceEnabled } from "@/lib/env"; import { getOrgMembership } from "@/lib/org-access"; import { validateOrgConfigYaml } from "@/lib/org-review-config"; import { recordRepositoryEnablementEvent } from "@/lib/repository-enablement"; @@ -459,36 +460,9 @@ export async function saveOrgSettings( } const db = getDb(); - const [currentSettings, entitlement] = await Promise.all([ - db - .select({ - apiKeyCiphertext: schema.orgSettings.apiKeyCiphertext, - apiAuthHeaderCiphertext: schema.orgSettings.apiAuthHeaderCiphertext, - }) - .from(schema.orgSettings) - .where(eq(schema.orgSettings.orgId, orgId)) - .limit(1) - .then((rows) => rows[0]), - db - .select({ subscriptionMode: schema.organizationEntitlements.subscriptionMode }) - .from(schema.organizationEntitlements) - .where(eq(schema.organizationEntitlements.orgId, orgId)) - .limit(1) - .then((rows) => rows[0]), - ]); - const removingByok = providerMode === "hosted" || apiKeyAction === "remove"; const requestedMode = removingByok ? "hosted" : "byok"; - if ( - (entitlement?.subscriptionMode === "hosted" || - entitlement?.subscriptionMode === "byok") && - entitlement.subscriptionMode !== requestedMode - ) { - return { - status: "error", - message: `Your billed plan uses ${entitlement.subscriptionMode === "byok" ? "BYOK" : "hosted inference"}. Contact Postil before switching inference mode.`, - }; - } + const now = new Date(); if (!removingByok) { if (!apiFormat) { return { status: "error", message: "Choose a supported API interface." }; @@ -502,16 +476,6 @@ export async function saveOrgSettings( if (!model) { return { status: "error", message: "Enter the primary model." }; } - if (apiKeyAction === "keep" && !currentSettings?.apiKeyCiphertext) { - return { status: "error", message: "Enter a provider key to enable BYOK." }; - } - if (apiAuthAction === "keep" && currentSettings?.apiAuthHeaderCiphertext) { - const storedHeader = unseal( - Buffer.from(currentSettings.apiAuthHeaderCiphertext), - getSealingKey(), - ); - validateAdditionalAuthHeader(storedHeader, apiFormat); - } } const base = { @@ -523,7 +487,7 @@ export async function saveOrgSettings( guardrailsMd, contentPolicyMd, sharedConfigEnabled, - updatedAt: new Date(), + updatedAt: now, }; // The key is write-only: set when provided, cleared when requested, @@ -569,21 +533,119 @@ export async function saveOrgSettings( return { status: "error", message: "Choose how to update additional authentication." }; } - await db - .insert(schema.orgSettings) - .values({ - orgId, - ...base, - apiKeyCiphertext: "apiKeyCiphertext" in keyUpdate ? keyUpdate.apiKeyCiphertext : null, - apiAuthHeaderCiphertext: - "apiAuthHeaderCiphertext" in authUpdate ? authUpdate.apiAuthHeaderCiphertext : null, - apiAuthValueCiphertext: - "apiAuthValueCiphertext" in authUpdate ? authUpdate.apiAuthValueCiphertext : null, - }) - .onConflictDoUpdate({ - target: schema.orgSettings.orgId, - set: { ...base, ...keyUpdate, ...authUpdate }, - }); + const modeError = await db.transaction(async (tx) => { + await tx.execute(sql` + SELECT "org_id" + FROM "organization_entitlements" + WHERE "org_id" = ${orgId} + FOR UPDATE + `); + await tx.execute(sql` + SELECT "org_id" + FROM "org_settings" + WHERE "org_id" = ${orgId} + FOR UPDATE + `); + const entitlement = ( + await tx + .select({ + subscriptionMode: schema.organizationEntitlements.subscriptionMode, + status: schema.organizationEntitlements.status, + trialEndsAt: schema.organizationEntitlements.trialEndsAt, + }) + .from(schema.organizationEntitlements) + .where(eq(schema.organizationEntitlements.orgId, orgId)) + .limit(1) + )[0]; + const currentSettings = ( + await tx + .select({ + apiKeyCiphertext: schema.orgSettings.apiKeyCiphertext, + apiAuthHeaderCiphertext: schema.orgSettings.apiAuthHeaderCiphertext, + }) + .from(schema.orgSettings) + .where(eq(schema.orgSettings.orgId, orgId)) + .limit(1) + )[0]; + const activeTrial = Boolean( + entitlement?.status === "trialing" && + entitlement.trialEndsAt && + entitlement.trialEndsAt > now, + ); + if ( + requestedMode === "hosted" && + !hostedInferenceEnabled() && + entitlement?.subscriptionMode !== "hosted" + ) { + return { + status: "error" as const, + message: "Hosted inference is paused. Use your provider.", + }; + } + if ( + (entitlement?.subscriptionMode === "hosted" || + entitlement?.subscriptionMode === "byok") && + entitlement.subscriptionMode !== requestedMode && + !activeTrial + ) { + return { + status: "error" as const, + message: `Your plan uses ${entitlement.subscriptionMode === "byok" ? "BYOK" : "hosted inference"}. Change the plan before switching inference mode.`, + }; + } + if (!removingByok && apiKeyAction === "keep" && !currentSettings?.apiKeyCiphertext) { + return { status: "error" as const, message: "Enter a provider key to enable BYOK." }; + } + if ( + !removingByok && + apiAuthAction === "keep" && + currentSettings?.apiAuthHeaderCiphertext + ) { + const storedHeader = unseal( + Buffer.from(currentSettings.apiAuthHeaderCiphertext), + getSealingKey(), + ); + validateAdditionalAuthHeader(storedHeader, apiFormat!); + } + + await tx + .insert(schema.orgSettings) + .values({ + orgId, + ...base, + apiKeyCiphertext: "apiKeyCiphertext" in keyUpdate ? keyUpdate.apiKeyCiphertext : null, + apiAuthHeaderCiphertext: + "apiAuthHeaderCiphertext" in authUpdate ? authUpdate.apiAuthHeaderCiphertext : null, + apiAuthValueCiphertext: + "apiAuthValueCiphertext" in authUpdate ? authUpdate.apiAuthValueCiphertext : null, + }) + .onConflictDoUpdate({ + target: schema.orgSettings.orgId, + set: { ...base, ...keyUpdate, ...authUpdate }, + }); + if (activeTrial) { + const switched = await tx + .update(schema.organizationEntitlements) + .set({ + subscriptionMode: requestedMode, + updatedBy: "trial-provider-mode", + updatedAt: now, + }) + .where( + and( + eq(schema.organizationEntitlements.orgId, orgId), + eq(schema.organizationEntitlements.status, "trialing"), + gt(schema.organizationEntitlements.trialEndsAt, now), + ), + ) + .returning({ orgId: schema.organizationEntitlements.orgId }); + if (switched.length !== 1) { + throw new Error("The free trial ended before the provider change was saved."); + } + } + return null; + }); + if (modeError) return modeError; revalidatePath(`/orgs/${slug}`); revalidatePath(`/orgs/${slug}/settings`); return { status: "success", message: "Organization settings saved." }; diff --git a/src/app/orgs/[slug]/billing/page.tsx b/src/app/orgs/[slug]/billing/page.tsx index 4ffba6f3..fc3cfabf 100644 --- a/src/app/orgs/[slug]/billing/page.tsx +++ b/src/app/orgs/[slug]/billing/page.tsx @@ -111,6 +111,7 @@ export default async function OrgBillingPage({ providerSettings?.hasKey ?? false, ); const entitlement = privateAccess.entitlement; + const activeTrial = privateAccess.reason === "active_trial" && entitlement?.trialEndsAt; const contactState = entitlement ? ( await db @@ -195,14 +196,18 @@ export default async function OrgBillingPage({

    Private repository access

    - {privateAccess.allowed + {activeTrial + ? "Free trial active" + : privateAccess.allowed ? "Private access ready" : entitlement ? "Private access paused" : "Public only"}

    - {privateAccess.allowed + {activeTrial + ? `Private-repository reviews are included through ${formatDateTime(activeTrial)}.` + : privateAccess.allowed ? `${entitlement?.subscriptionMode === "byok" ? "BYOK" : "Hosted"} private-repository reviews are enabled.` : entitlement ? "Check the plan status and provider setup below." @@ -227,12 +232,12 @@ export default async function OrgBillingPage({

    )} {!entitlement && ( - - Contact us to activate - + Start 30-day trial + )} {entitlement?.subscriptionMode === "hosted" && (

    @@ -258,7 +263,9 @@ export default async function OrgBillingPage({

    Plan

    - {entitlement + {activeTrial + ? "$0" + : entitlement ? `$${ entitlement.subscriptionMode === "byok" ? BYOK_ACTIVE_AUTHOR_MONTHLY_USD @@ -267,7 +274,11 @@ export default async function OrgBillingPage({ : "$0"}

    - {entitlement ? "per billed private-PR author" : "for public repositories"} + {activeTrial + ? `through ${formatDateTime(activeTrial)}` + : entitlement + ? "per billed private-PR author" + : "for public repositories"}

    Public repositories are free. Repositories are not billing units. diff --git a/src/app/orgs/[slug]/settings-form.tsx b/src/app/orgs/[slug]/settings-form.tsx index 55705fdb..a44afbba 100644 --- a/src/app/orgs/[slug]/settings-form.tsx +++ b/src/app/orgs/[slug]/settings-form.tsx @@ -9,6 +9,8 @@ interface SettingsFormProps { slug: string; billedMode: "hosted" | "byok" | null; managedReviewsPaused: boolean; + hostedInferenceAvailable: boolean; + trialCanSwitchProvider: boolean; settings: | { apiBase: string | null; @@ -43,6 +45,8 @@ export function SettingsForm({ settings, billedMode, managedReviewsPaused, + hostedInferenceAvailable, + trialCanSwitchProvider, sharedSnapshot, sharedSourceFullName, sharedSourceInstalled, @@ -80,19 +84,28 @@ export function SettingsForm({ name="providerMode" value="hosted" checked={!bringOwnKey} - disabled={billedMode !== "hosted"} + disabled={ + !hostedInferenceAvailable || + (billedMode !== "hosted" && !trialCanSwitchProvider) + } onChange={() => setBringOwnKey(false)} className="h-4 w-4 accent-[#2F6F4E]" /> Hosted by Postil - {managedReviewsPaused || billedMode !== "hosted" ? " (paused)" : ""} + {!hostedInferenceAvailable || + managedReviewsPaused || + (billedMode !== "hosted" && !trialCanSwitchProvider) + ? " (paused)" + : ""} - {managedReviewsPaused + {!hostedInferenceAvailable + ? "Hosted inference is paused." + : managedReviewsPaused ? "Managed reviews are paused." - : billedMode === "hosted" + : billedMode === "hosted" || trialCanSwitchProvider ? "Postil chooses and operates the models." : "New hosted inference setup is unavailable."} @@ -104,7 +117,7 @@ export function SettingsForm({ name="providerMode" value="byok" checked={bringOwnKey} - disabled={billedMode === "hosted"} + disabled={billedMode === "hosted" && !trialCanSwitchProvider} onChange={() => setBringOwnKey(true)} className="h-4 w-4 accent-[#2F6F4E]" /> @@ -115,19 +128,21 @@ export function SettingsForm({

    - {billedMode ? ( + {trialCanSwitchProvider ? ( + <> + {hostedInferenceAvailable + ? "Choose hosted inference or your provider during the free trial." + : "Use your provider during the free trial. Hosted inference is paused."}{" "} + + View trial. + + + ) : billedMode ? ( <> Your private-repository plan uses {billedMode === "byok" ? "BYOK" : "hosted inference"}.{" "} View billing. - {" "} - - Contact us to change plans. - ) : ( <> diff --git a/src/app/orgs/[slug]/settings/page.tsx b/src/app/orgs/[slug]/settings/page.tsx index 05d46c01..4a2c2594 100644 --- a/src/app/orgs/[slug]/settings/page.tsx +++ b/src/app/orgs/[slug]/settings/page.tsx @@ -61,7 +61,8 @@ export default async function OrgSettingsPage({ .where(eq(schema.orgSettings.orgId, org.id)) .limit(1) )[0]; - const managedReviewsPaused = !hostedInferenceEnabled() && !(settings?.hasKey ?? false); + const hostedInferenceAvailable = hostedInferenceEnabled(); + const managedReviewsPaused = !hostedInferenceAvailable && !(settings?.hasKey ?? false); const sharedSnapshot = ( await db .select({ @@ -79,7 +80,11 @@ export default async function OrgSettingsPage({ )[0]; const entitlement = ( await db - .select({ subscriptionMode: schema.organizationEntitlements.subscriptionMode }) + .select({ + subscriptionMode: schema.organizationEntitlements.subscriptionMode, + status: schema.organizationEntitlements.status, + trialEndsAt: schema.organizationEntitlements.trialEndsAt, + }) .from(schema.organizationEntitlements) .where(eq(schema.organizationEntitlements.orgId, org.id)) .limit(1) @@ -241,6 +246,12 @@ export default async function OrgSettingsPage({ slug={org.slug} settings={settings} managedReviewsPaused={managedReviewsPaused} + hostedInferenceAvailable={hostedInferenceAvailable} + trialCanSwitchProvider={Boolean( + entitlement?.status === "trialing" && + entitlement.trialEndsAt && + entitlement.trialEndsAt > now, + )} sharedSnapshot={sharedSnapshot} sharedSourceFullName={sharedSnapshot?.sourceFullName ?? sharedSourceFullName} sharedSourceInstalled={sharedSourceInstalled} diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx index 7d1058a8..8f49ed2d 100644 --- a/src/app/pricing/page.tsx +++ b/src/app/pricing/page.tsx @@ -9,12 +9,12 @@ import { export const metadata: Metadata = { title: "Pricing", - description: `Public repositories are free with BYOK. Hosted enrollment is paused. BYOK is $${BYOK_ACTIVE_AUTHOR_MONTHLY_USD} per active private-PR author each month.`, + description: `Start with a 30-day BYOK trial. Public repositories are free with BYOK. Private BYOK is $${BYOK_ACTIVE_AUTHOR_MONTHLY_USD} per active author each month after the trial.`, alternates: { canonical: "/pricing" }, openGraph: { title: "Postil pricing", description: - "BYOK is available for public and private repositories. Hosted enrollment is paused.", + "Start with a 30-day BYOK trial. Pay only for active private-repository authors after the trial.", url: "https://postil.dev/pricing", images: ["/opengraph-image"], }, @@ -47,9 +47,9 @@ export default function PricingPage() { Private code, priced by active authors.

    - Public repositories are free with your model provider. Private plans - count only authors whose pull requests Postil reviews. Repositories are - not billed. + Start with a 30-day free trial using your model provider. Public repositories + are free with your provider. Private plans count only authors whose pull + requests Postil reviews. Repositories are not billed.

      +
    • 30-day free trial
    • Your provider, models, and credentials
    • Provider usage billed directly to you
    • Review volume is not a billing unit
    - - Contact us to activate + + Start 30-day trial
    @@ -99,9 +100,9 @@ export default function PricingPage() {
  • Postil operates model access
  • Review volume is not a billing unit
  • - - Ask about hosted access - + + Enrollment paused +
    diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index b3f91ecb..ebaf10b1 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -124,8 +124,10 @@ export default function PrivacyPage() {
  • Brevo sends verification messages when an - organization administrator configures a billing contact. Review findings - remain in GitHub and the authenticated Postil dashboard. + organization administrator configures a billing contact and sends + the operator a one-time trial-start notice containing GitHub account + and App installation metadata. Review findings remain in GitHub and + the authenticated Postil dashboard.
  • @@ -141,13 +143,17 @@ export default function PrivacyPage() { daily identifier from the project, hostname, IP address, and user agent, then discards the raw IP address. DNT and Global Privacy Control disable browser and request analytics. Session replay, - person profiles, surveys, heatmaps, exception capture, and - autocaptured clicks are disabled. Server-side + person profiles, surveys, heatmaps, and autocaptured clicks are + disabled. Server-side request telemetry records sanitized path, referrer origin/public path, campaign parameters, user agent, and Cloudflare bot metadata when present; it does not send IP addresses, arbitrary query strings, - or protected dashboard paths. Authentication sets one first-party - session cookie after sign-in; analytics do not use it. + or protected dashboard paths. Operational monitoring sends fixed event + names and scrubbed exception stacks with project-relative code + locations. It excludes request content, source code, repository names, + prompts, model output, credentials, email addresses, and absolute file + paths. Authentication sets one first-party session cookie after sign-in; + analytics do not use it.

    Where data is processed (residency)

    diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx index 1dfd4f89..b7223d13 100644 --- a/src/app/terms/page.tsx +++ b/src/app/terms/page.tsx @@ -62,8 +62,8 @@ export default function TermsPage() { The organization is the customer. Private plans are billed monthly by active private-PR author at the rates on the{" "} pricing page.{" "} - Hosted enrollment is paused. A configured BYOK provider bills its - model usage separately. + New GitHub owners receive one 30-day BYOK trial. A configured BYOK + provider bills its model usage separately. Hosted enrollment is paused.

    An active author is a GitHub identity, including a bot or service diff --git a/src/components/forge-install-tabs.tsx b/src/components/forge-install-tabs.tsx index f95cfe6d..b66a9925 100644 --- a/src/components/forge-install-tabs.tsx +++ b/src/components/forge-install-tabs.tsx @@ -89,15 +89,16 @@ function GitHubPanel({ githubAppUrl }: { githubAppUrl: string }) {

    01

    GitHub App with BYOK

    - Automatic reviews after you connect your model provider. + A 30-day free trial after you connect your model provider.

    Choose all repositories or a selected set during installation. - Then open organization settings and configure the provider API, - model, and key. New non-draft pull requests are reviewed after - setup. Draft pull requests are skipped until they are marked ready. + The 30-day trial starts automatically. Open organization settings to + connect your provider API, model, and key. New non-draft pull + requests are reviewed after setup. Draft pull requests are skipped + until they are marked ready. Existing open pull requests are not reviewed retroactively unless a review is requested again.

    diff --git a/src/lib/email-verification.ts b/src/lib/email-verification.ts index cc582e2a..7924233e 100644 --- a/src/lib/email-verification.ts +++ b/src/lib/email-verification.ts @@ -87,6 +87,24 @@ export async function sendVerificationEmail(input: { idempotencyKey: string; apiKey: string; fetchImpl?: Fetch; +}): Promise<{ messageId: string | null }> { + return sendTransactionalEmail({ + ...input, + text: [ + ...input.text, + "", + "This link expires in 24 hours. If you did not request this, ignore it.", + ], + }); +} + +export async function sendTransactionalEmail(input: { + recipient: string; + subject: string; + text: string[]; + idempotencyKey: string; + apiKey: string; + fetchImpl?: Fetch; }): Promise<{ messageId: string | null }> { const response = await (input.fetchImpl ?? fetch)(BREVO_SEND_URL, { method: "POST", @@ -109,7 +127,7 @@ export async function sendVerificationEmail(input: { }, to: [{ email: input.recipient }], subject: input.subject, - textContent: [...input.text, "", "This link expires in 24 hours. If you did not request this, ignore it."].join("\n"), + textContent: input.text.join("\n"), headers: { "Idempotency-Key": input.idempotencyKey }, }), signal: AbortSignal.timeout(BREVO_TIMEOUT_MS), @@ -122,7 +140,7 @@ export async function sendVerificationEmail(input: { parsed = {}; } if (!response.ok && parsed.code !== "duplicate_parameter") { - throw new Error(`Brevo verification email failed: ${response.status}`); + throw new Error(`Brevo transactional email failed: ${response.status}`); } return { messageId: typeof parsed.messageId === "string" ? parsed.messageId : null }; } diff --git a/src/lib/env.ts b/src/lib/env.ts index ca6e029e..5b159580 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -175,25 +175,32 @@ const ENV_SPECS: EnvVarSpec[] = [ }, { name: "BREVO_API_KEY", - purpose: "Brevo transactional email API key for billing-contact verification", + purpose: "Brevo transactional email API key", example: "xkeysib-...", scope: ["web", "worker"], optional: true, }, { name: "POSTIL_EMAIL_FROM_EMAIL", - purpose: "Verified Brevo sender address for billing-contact verification", + purpose: "Verified Brevo sender address for transactional email", example: "reviews@mail.postil.dev", scope: ["web", "worker"], optional: true, }, { name: "POSTIL_EMAIL_FROM_NAME", - purpose: "Sender display name for billing-contact verification", + purpose: "Sender display name for transactional email", example: "Postil", scope: ["web", "worker"], optional: true, }, + { + name: "POSTIL_OPERATOR_ALERT_EMAIL", + purpose: "Verified operator inbox for one-time trial signup alerts", + example: "ops@example.com", + scope: ["web", "worker"], + optional: true, + }, { name: "POSTIL_ESCALATION_FROM_EMAIL", purpose: "Legacy alias for POSTIL_EMAIL_FROM_EMAIL", @@ -431,6 +438,22 @@ export function validateEnv(processKind: "web" | "worker"): void { ); } validateOperationalTelemetryEnv(processKind); + validateOperatorAlertEnv(processKind); +} + +function validateOperatorAlertEnv(processKind: "web" | "worker"): void { + const recipient = process.env.POSTIL_OPERATOR_ALERT_EMAIL?.trim(); + if (!recipient) return; + if (recipient.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipient)) { + throw new Error( + `Postil ${processKind} cannot start: POSTIL_OPERATOR_ALERT_EMAIL must be a valid email address.`, + ); + } + if (!process.env.BREVO_API_KEY?.trim()) { + throw new Error( + `Postil ${processKind} cannot start: POSTIL_OPERATOR_ALERT_EMAIL requires BREVO_API_KEY.`, + ); + } } function safePublicUrlValidationDetail(error: unknown): string | undefined { diff --git a/src/lib/github/installation-sync.ts b/src/lib/github/installation-sync.ts index 68264091..8612fb85 100644 --- a/src/lib/github/installation-sync.ts +++ b/src/lib/github/installation-sync.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import { getDb, schema } from "@/lib/db"; -import { requireEnv } from "@/lib/env"; +import { hostedInferenceEnabled, requireEnv } from "@/lib/env"; import { apiBase, buildAppJwt, @@ -10,6 +10,7 @@ import { } from "@/lib/github/app-auth"; import { redactSecrets } from "@/lib/redact"; import { recordRepositoryEnablementEvent } from "@/lib/repository-enablement"; +import { grantSelfServiceTrial } from "@/lib/self-service-trial"; /** * Installation and organization persistence shared by the webhook receiver @@ -251,25 +252,51 @@ export async function upsertInstallation( ): Promise { const db = getDb(); const orgId = await findOrCreateOrg(account); - const upserted = await db - .insert(schema.installations) - .values({ - githubInstallationId: installation.id, - orgId, - accountLogin: account.login, - accountType: account.type ?? "User", - suspended: installation.suspended ?? false, - }) - .onConflictDoUpdate({ - target: schema.installations.githubInstallationId, - set: { + const accountType = account.type ?? "User"; + return db.transaction(async (tx) => { + const upserted = await tx + .insert(schema.installations) + .values({ + githubInstallationId: installation.id, orgId, accountLogin: account.login, + accountType, suspended: installation.suspended ?? false, - }, - }) - .returning({ id: schema.installations.id }); - return upserted[0]?.id; + }) + .onConflictDoUpdate({ + target: schema.installations.githubInstallationId, + set: { + orgId, + accountLogin: account.login, + accountType, + suspended: installation.suspended ?? false, + }, + }) + .returning({ id: schema.installations.id }); + const installationRowId = upserted[0]?.id; + if (installationRowId === undefined) return undefined; + const organization = ( + await tx + .select({ slug: schema.organizations.slug }) + .from(schema.organizations) + .where(eq(schema.organizations.id, orgId)) + .limit(1) + )[0]; + if (!organization) throw new Error("installation organization is missing"); + + if (!(installation.suspended ?? false)) { + await grantSelfServiceTrial(tx, { + orgId, + orgSlug: organization.slug, + accountLogin: account.login, + accountType, + githubOwnerId: account.id, + githubInstallationId: installation.id, + subscriptionMode: hostedInferenceEnabled() ? "hosted" : "byok", + }); + } + return installationRowId; + }); } function githubHeaders(bearer: string): Record { diff --git a/src/lib/github/webhook-handler.ts b/src/lib/github/webhook-handler.ts index 60e6ca2a..335c4868 100644 --- a/src/lib/github/webhook-handler.ts +++ b/src/lib/github/webhook-handler.ts @@ -347,10 +347,10 @@ async function handleInstallation(payload: InstallationEventPayload): Promise { intent?: "fail" | "neutralize"; } +/** A durable, idempotent notification to the verified Postil operator inbox. */ +export interface OperatorAlertJobPayload extends Record { + event: "trial_started"; + orgId: number; + orgSlug: string; + accountLogin: string; + accountType: string; + githubOwnerId: number; + githubInstallationId: number; + trialEndsAt: string; +} + export interface WebhookDispatchJobPayload extends Record { deliveryId: string; } diff --git a/src/lib/self-service-trial.ts b/src/lib/self-service-trial.ts new file mode 100644 index 00000000..ff357a23 --- /dev/null +++ b/src/lib/self-service-trial.ts @@ -0,0 +1,72 @@ +import type { Database } from "@/lib/db"; +import { schema } from "@/lib/db"; +import { optionalEnv } from "@/lib/env"; + +export const SELF_SERVICE_TRIAL_DAYS = 30; +const SELF_SERVICE_TRIAL_DURATION_MS = + SELF_SERVICE_TRIAL_DAYS * 24 * 60 * 60 * 1_000; + +// An internal service-protection ceiling, not a customer billing unit. The +// reservation layer remains fail-closed while ordinary trial usage has ample +// headroom. +export const SELF_SERVICE_TRIAL_HOSTED_USAGE_MICROS = 100_000_000; + +type TrialWriteDatabase = Pick; + +export interface SelfServiceTrialInput { + orgId: number; + orgSlug: string; + accountLogin: string; + accountType: string; + githubOwnerId: number; + githubInstallationId: number; + subscriptionMode: "hosted" | "byok"; +} + +/** Grant one owner-scoped trial and enqueue its alert in the same transaction. */ +export async function grantSelfServiceTrial( + db: TrialWriteDatabase, + input: SelfServiceTrialInput, + now = new Date(), +): Promise<{ granted: boolean; trialEndsAt: Date | null }> { + const trialEndsAt = new Date(now.getTime() + SELF_SERVICE_TRIAL_DURATION_MS); + const [created] = await db + .insert(schema.organizationEntitlements) + .values({ + orgId: input.orgId, + subscriptionMode: input.subscriptionMode, + status: "trialing", + trialEndsAt, + periodStartsAt: now, + periodEndsAt: trialEndsAt, + includedUsageMicros: SELF_SERVICE_TRIAL_HOSTED_USAGE_MICROS, + overageHardCapMicros: 0, + includedUsageCents: SELF_SERVICE_TRIAL_HOSTED_USAGE_MICROS / 10_000, + overageHardCapCents: 0, + updatedBy: "self-service-trial", + updatedAt: now, + }) + .onConflictDoNothing({ target: schema.organizationEntitlements.orgId }) + .returning({ orgId: schema.organizationEntitlements.orgId }); + + if (!created) return { granted: false, trialEndsAt: null }; + + if (optionalEnv("POSTIL_OPERATOR_ALERT_EMAIL")) { + await db.insert(schema.jobs).values({ + kind: "operator-alert", + payload: { + event: "trial_started", + orgId: input.orgId, + orgSlug: input.orgSlug, + accountLogin: input.accountLogin, + accountType: input.accountType, + githubOwnerId: input.githubOwnerId, + githubInstallationId: input.githubInstallationId, + trialEndsAt: trialEndsAt.toISOString(), + }, + maxAttempts: 5, + }); + } + + return { granted: true, trialEndsAt }; +} diff --git a/src/worker/operator-alert.ts b/src/worker/operator-alert.ts new file mode 100644 index 00000000..2b436738 --- /dev/null +++ b/src/worker/operator-alert.ts @@ -0,0 +1,77 @@ +import { + normalizeVerificationEmail, + sendTransactionalEmail, +} from "@/lib/email-verification"; +import { requireEnv } from "@/lib/env"; + +export interface OperatorAlertJobPayload extends Record { + event: "trial_started"; + orgId: number; + orgSlug: string; + accountLogin: string; + accountType: string; + githubOwnerId: number; + githubInstallationId: number; + trialEndsAt: string; +} + +export async function runOperatorAlertJob( + payload: OperatorAlertJobPayload, +): Promise { + validatePayload(payload); + const trialEndsAt = new Date(payload.trialEndsAt); + const recipient = normalizeVerificationEmail( + requireEnv("POSTIL_OPERATOR_ALERT_EMAIL"), + "POSTIL_OPERATOR_ALERT_EMAIL must be a valid email address.", + ); + if (!recipient) throw new Error("POSTIL_OPERATOR_ALERT_EMAIL is required"); + const dashboardUrl = new URL( + `/orgs/${encodeURIComponent(payload.orgSlug)}`, + requireEnv("POSTIL_PUBLIC_URL"), + ).toString(); + + await sendTransactionalEmail({ + recipient, + subject: `New Postil trial: ${payload.accountLogin}`, + text: [ + "A GitHub owner started a 30-day Postil trial.", + "", + `Account: ${payload.accountLogin}`, + `Account type: ${payload.accountType}`, + `GitHub owner ID: ${payload.githubOwnerId}`, + `GitHub App installation ID: ${payload.githubInstallationId}`, + `Trial ends: ${trialEndsAt.toISOString()}`, + `Dashboard: ${dashboardUrl}`, + ], + idempotencyKey: `operator-alert-trial-started-${payload.githubOwnerId}`, + apiKey: requireEnv("BREVO_API_KEY"), + }); +} + +function validatePayload(payload: OperatorAlertJobPayload): void { + const trialEndsAt = new Date(payload.trialEndsAt); + if ( + payload.event !== "trial_started" || + !Number.isSafeInteger(payload.orgId) || + payload.orgId <= 0 || + !Number.isSafeInteger(payload.githubOwnerId) || + payload.githubOwnerId <= 0 || + !Number.isSafeInteger(payload.githubInstallationId) || + payload.githubInstallationId <= 0 || + !safeLabel(payload.orgSlug, 160) || + !safeLabel(payload.accountLogin, 160) || + !safeLabel(payload.accountType, 40) || + !Number.isFinite(trialEndsAt.getTime()) + ) { + throw new Error("operator alert job payload is malformed"); + } +} + +function safeLabel(value: unknown, maxLength: number): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= maxLength && + !/[\u0000-\u001f\u007f]/.test(value) + ); +} diff --git a/src/worker/runner.ts b/src/worker/runner.ts index 6667107b..13feab38 100644 --- a/src/worker/runner.ts +++ b/src/worker/runner.ts @@ -33,6 +33,10 @@ import { } from "./billing-contact-verification"; import { isPermanentFailure } from "./failure-classifier"; import { runGateStateSyncJob } from "./gate-state-sync"; +import { + runOperatorAlertJob, + type OperatorAlertJobPayload, +} from "./operator-alert"; import { postRespondFailureComment, runRespondDeliveryJob, @@ -57,6 +61,7 @@ export const PROCESSABLE_JOB_KINDS = [ "respond", "respond-delivery", "billing-contact-verification", + "operator-alert", "gate-state-sync", "check-run-cleanup", "respond-failure-comment", @@ -108,6 +113,9 @@ async function handleJob( job.payload as BillingContactVerificationJobPayload, ); break; + case "operator-alert": + await runOperatorAlertJob(job.payload as OperatorAlertJobPayload); + break; case "gate-state-sync": await runGateStateSyncJob(job.payload as GateStateSyncJobPayload); break; diff --git a/tests/billing-page-auth.test.ts b/tests/billing-page-auth.test.ts index ac5b51e5..84da1be5 100644 --- a/tests/billing-page-auth.test.ts +++ b/tests/billing-page-auth.test.ts @@ -157,7 +157,7 @@ describe("organization billing page auth", () => { expect(markup).toContain("Public only"); expect(markup).toContain("public ยท free"); expect(markup).toContain("$0"); - expect(markup).toContain("Contact us to activate"); + expect(markup).toContain("Start 30-day trial"); expect(markup).not.toContain("billing contact: not set"); expect(markup).not.toContain("included usage: $0.00"); expect(markup).not.toContain("overage hard cap: not set"); diff --git a/tests/env-validation.test.ts b/tests/env-validation.test.ts index eb5513ee..8bed7935 100644 --- a/tests/env-validation.test.ts +++ b/tests/env-validation.test.ts @@ -24,6 +24,8 @@ const MANAGED_ENV = [ "POSTHOG_LOG_INFO_SAMPLE_RATE", "POSTHOG_LOG_MAX_PER_MINUTE", "POSTIL_RELEASE_SHA", + "POSTIL_OPERATOR_ALERT_EMAIL", + "BREVO_API_KEY", ] as const; const originalEnv = new Map( MANAGED_ENV.map((name) => [name, process.env[name]]), @@ -272,6 +274,19 @@ describe("web startup environment validation", () => { expect(() => validateEnv("web")).not.toThrow(); }); + + test("requires complete operator alert email configuration", () => { + configureRequiredWebEnvironment(); + process.env.POSTIL_OPERATOR_ALERT_EMAIL = "operator@example.com"; + delete process.env.BREVO_API_KEY; + expect(() => validateEnv("web")).toThrow(/requires BREVO_API_KEY/); + + process.env.BREVO_API_KEY = "brevo-test-key"; + expect(() => validateEnv("web")).not.toThrow(); + + process.env.POSTIL_OPERATOR_ALERT_EMAIL = "invalid"; + expect(() => validateEnv("web")).toThrow(/must be a valid email address/); + }); }); function configureRequiredWebEnvironment(): void { diff --git a/tests/operator-alert-job.test.ts b/tests/operator-alert-job.test.ts new file mode 100644 index 00000000..3b4a2196 --- /dev/null +++ b/tests/operator-alert-job.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +let sentInput: Record | undefined; + +mock.module("@/lib/email-verification", () => ({ + normalizeVerificationEmail: (value: string) => value.trim().toLowerCase(), + sendTransactionalEmail: async (input: Record) => { + sentInput = input; + return { messageId: "brevo-message-operator-1" }; + }, +})); + +const { runOperatorAlertJob } = await import("@/worker/operator-alert"); + +describe("operator alert job", () => { + beforeEach(() => { + process.env.POSTIL_OPERATOR_ALERT_EMAIL = "operator@example.com"; + process.env.POSTIL_PUBLIC_URL = "https://postil.dev"; + process.env.BREVO_API_KEY = "brevo-key"; + sentInput = undefined; + }); + + test("sends one idempotent trial-start alert without repository content", async () => { + await runOperatorAlertJob({ + event: "trial_started", + orgId: 7, + orgSlug: "acme", + accountLogin: "Acme", + accountType: "Organization", + githubOwnerId: 700, + githubInstallationId: 701, + trialEndsAt: "2026-08-17T12:00:00.000Z", + }); + + expect(sentInput).toMatchObject({ + recipient: "operator@example.com", + subject: "New Postil trial: Acme", + idempotencyKey: "operator-alert-trial-started-700", + apiKey: "brevo-key", + }); + const text = (sentInput?.text as string[]).join("\n"); + expect(text).toContain("A GitHub owner started a 30-day Postil trial."); + expect(text).toContain("Dashboard: https://postil.dev/orgs/acme"); + expect(text).not.toMatch(/repository|pull request|source code/i); + }); + + test("rejects malformed payloads before sending", async () => { + await expect( + runOperatorAlertJob({ + event: "trial_started", + orgId: 7, + orgSlug: "bad\nslug", + accountLogin: "Acme", + accountType: "Organization", + githubOwnerId: 700, + githubInstallationId: 701, + trialEndsAt: "2026-08-17T12:00:00.000Z", + }), + ).rejects.toThrow("operator alert job payload is malformed"); + expect(sentInput).toBeUndefined(); + }); +}); diff --git a/tests/org-settings-actions.test.ts b/tests/org-settings-actions.test.ts index 1af9f904..efc6a5bf 100644 --- a/tests/org-settings-actions.test.ts +++ b/tests/org-settings-actions.test.ts @@ -52,6 +52,8 @@ const schema = { organizationEntitlements: { orgId: "organization_entitlements.org_id", subscriptionMode: "organization_entitlements.subscription_mode", + status: "organization_entitlements.status", + trialEndsAt: "organization_entitlements.trial_ends_at", billingContactEmail: "organization_entitlements.billing_contact_email", billingContactPending: "organization_entitlements.billing_contact_pending", billingContactVerifiedAt: "organization_entitlements.billing_contact_verified_at", @@ -216,6 +218,9 @@ function fakeDb() { transaction(callback: (tx: unknown) => Promise) { return callback(fakeDb()); }, + execute() { + return Promise.resolve({ rows: billingRows }); + }, update() { const chain = { set(values: Record) { @@ -276,6 +281,7 @@ function approvalForm(): FormData { } beforeEach(() => { + delete process.env.POSTIL_HOSTED_INFERENCE_ENABLED; process.env.POSTIL_SEALING_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; sessionUser = { id: 10 }; @@ -621,7 +627,84 @@ describe("saveOrgSettings", () => { ); expect(result).toEqual({ status: "error", - message: "Your billed plan uses hosted inference. Contact Postil before switching inference mode.", + message: "Your plan uses hosted inference. Change the plan before switching inference mode.", + }); + expect(insertCount).toBe(0); + }); + + test("lets an active trial switch from hosted inference to BYOK atomically", async () => { + billingRows = [{ + subscriptionMode: "hosted", + status: "trialing", + trialEndsAt: new Date(Date.now() + 60_000), + }]; + updateResultRows = [{ orgId: 20 }]; + + const result = await saveOrgSettings( + null, + byokForm({ apiKeyAction: "replace", apiKey: "sk-test-secret" }), + ); + + expect(result).toEqual({ status: "success", message: "Organization settings saved." }); + expect(updatedValues).toMatchObject({ + subscriptionMode: "byok", + updatedBy: "trial-provider-mode", + }); + expect(conflictSet).toHaveProperty("apiKeyCiphertext"); + }); + + test("writes the requested mode even when an active trial already has that mode", async () => { + billingRows = [{ + subscriptionMode: "byok", + status: "trialing", + trialEndsAt: new Date(Date.now() + 60_000), + }]; + updateResultRows = [{ orgId: 20 }]; + + const result = await saveOrgSettings( + null, + byokForm({ apiKeyAction: "replace", apiKey: "sk-test-secret" }), + ); + + expect(result).toEqual({ status: "success", message: "Organization settings saved." }); + expect(updatedValues).toMatchObject({ + subscriptionMode: "byok", + updatedBy: "trial-provider-mode", + }); + }); + + test("does not let an expired trial change provider mode", async () => { + billingRows = [{ + subscriptionMode: "hosted", + status: "trialing", + trialEndsAt: new Date(Date.now() - 60_000), + }]; + + const result = await saveOrgSettings( + null, + byokForm({ apiKeyAction: "replace", apiKey: "sk-test-secret" }), + ); + + expect(result).toEqual({ + status: "error", + message: "Your plan uses hosted inference. Change the plan before switching inference mode.", + }); + expect(insertCount).toBe(0); + }); + + test("does not let a BYOK trial select hosted inference while hosted is paused", async () => { + process.env.POSTIL_HOSTED_INFERENCE_ENABLED = "0"; + billingRows = [{ + subscriptionMode: "byok", + status: "trialing", + trialEndsAt: new Date(Date.now() + 60_000), + }]; + + const result = await saveOrgSettings(null, settingsForm()); + + expect(result).toEqual({ + status: "error", + message: "Hosted inference is paused. Use your provider.", }); expect(insertCount).toBe(0); }); @@ -643,9 +726,11 @@ describe("SettingsForm API key handling", () => { expect(source).toContain('value="anthropic"'); expect(source).toContain('type="checkbox"'); expect(source).toContain('type="radio"'); - expect(source).toContain('disabled={billedMode !== "hosted"}'); - expect(source).toContain('disabled={billedMode === "hosted"}'); + expect(source).toContain("!hostedInferenceAvailable"); + expect(readFileSync("src/app/orgs/[slug]/actions.ts", "utf8")).toContain("FOR UPDATE"); + expect(source).toContain('disabled={billedMode === "hosted" && !trialCanSwitchProvider}'); expect(source).toContain("New hosted inference setup is unavailable."); + expect(source).toContain("Choose hosted inference or your provider during the free trial."); expect(source).toContain("Use only a provider you trust with that code."); expect(source).toContain("Private repositories remain inactive until a matching plan"); expect(source).toContain("Shared owner configuration is disabled. The stored snapshot is not used."); diff --git a/tests/pricing-policy.test.ts b/tests/pricing-policy.test.ts index 999548b6..051cc5c6 100644 --- a/tests/pricing-policy.test.ts +++ b/tests/pricing-policy.test.ts @@ -30,11 +30,11 @@ describe("pricing policy", () => { expect(terms).toContain("Repository count and review count are not billing units"); expect(terms).toContain("provider-side budgets and"); expect(terms).toContain("hard limits where the provider supports them"); - expect(pricing).toContain("Public repositories are free with your model provider"); - expect(pricing).toContain("Enrollment is paused"); + expect(pricing).toMatch(/Public repositories\s+are free with your provider/); + expect(pricing).toContain("30-day free trial"); expect(pricing).toContain('href="/contact"'); expect(pricing).toContain("Install with BYOK"); - expect(pricing).toContain("Contact us to activate"); + expect(pricing).toContain("Start 30-day trial"); expect(terms).toContain("Public-repository App reviews are free"); expect(terms).toContain("materially beyond ordinary interactive"); expect(terms).not.toContain("Where practicable"); @@ -54,7 +54,7 @@ describe("pricing policy", () => { expect(byok).toBeGreaterThan(0); expect(hosted).toBeGreaterThan(byok); expect(pricing.slice(byok, hosted)).toContain("btn-primary"); - expect(pricing.slice(byok, hosted)).toContain('href="/contact"'); + expect(pricing.slice(byok, hosted)).toContain('href="/install"'); expect(pricing.slice(hosted)).toContain("btn-secondary"); const pricingSection = homepage.slice(homepage.indexOf("{/* 07 - Pricing teaser */}")); expect(pricingSection).toContain("md:grid-cols-2 xl:grid-cols-4"); @@ -63,15 +63,14 @@ describe("pricing policy", () => { ); }); - test("structured metadata advertises only the available commercial offer", () => { + test("structured metadata advertises the available self-service offer", () => { const layout = source("src/app/layout.tsx"); expect(layout).not.toContain('name: "Hosted"'); - expect(layout).not.toContain("price: String(HOSTED_ACTIVE_AUTHOR_MONTHLY_USD)"); expect(layout).toContain('name: "BYOK"'); expect(layout).toContain("price: String(BYOK_ACTIVE_AUTHOR_MONTHLY_USD)"); }); - test("keeps public setup surfaces BYOK-first while hosted enrollment is paused", () => { + test("keeps public setup surfaces self-service with a BYOK trial", () => { const setupCopy = [ source("src/app/install/page.tsx"), source("src/app/docs/page.tsx"), @@ -83,14 +82,14 @@ describe("pricing policy", () => { const privacy = source("src/app/privacy/page.tsx"); const billing = source("src/app/orgs/[slug]/billing/page.tsx"); - expect(setupCopy).toContain("GitHub App with BYOK"); - expect(setupCopy).toContain("connect your model provider"); + expect(setupCopy).toContain("GitHub App"); + expect(setupCopy).toContain("30-day free trial"); expect(setupCopy).not.toMatch(/zero.configuration/i); expect(setupCopy).not.toContain("Postil's hosted default model"); - expect(settings).toContain('disabled={billedMode !== "hosted"}'); + expect(settings).toContain("!hostedInferenceAvailable"); + expect(setupCopy).toContain("model provider"); expect(settings).toContain("New hosted inference setup is unavailable"); - expect(privacy).toContain("new hosted enrollment is paused"); - expect(privacy).toContain("Existing hosted plans"); + expect(privacy).toContain("Existing hosted plans use"); expect(billing).not.toMatch(/included usage|overage hard cap|credit balance|credit grants/i); expect(billing).not.toContain("calculateBillingCreditBalance"); }); diff --git a/tests/webhook-handlers.test.ts b/tests/webhook-handlers.test.ts index 8909ca53..8ba31749 100644 --- a/tests/webhook-handlers.test.ts +++ b/tests/webhook-handlers.test.ts @@ -186,6 +186,8 @@ describeDb("webhook handler behaviour", () => { authorLogin: "admin", }; delete process.env.POSTIL_RESPOND_HOURLY_CAP; + delete process.env.POSTIL_HOSTED_INFERENCE_ENABLED; + process.env.POSTIL_OPERATOR_ALERT_EMAIL = "operator@example.com"; await pool.query("TRUNCATE respond_deliveries, jobs RESTART IDENTITY"); await pool.query("TRUNCATE webhook_deliveries"); await pool.query( @@ -403,6 +405,123 @@ describeDb("webhook handler behaviour", () => { }); }); + test("installation grants one owner-scoped trial and reinstall cannot reset it", async () => { + const account = { id: 9090, login: "NewCustomer", type: "Organization" }; + const created = { + action: "created", + installation: { id: 8080, account, suspended_at: null }, + repositories: [], + }; + + expect((await post("installation", created, "trial-created-1")).status).toBe(200); + + const first = await pool.query<{ + org_id: string; + status: string; + subscription_mode: string; + trial_ends_at: Date; + period_starts_at: Date; + period_ends_at: Date; + included_usage_micros: string; + }>( + `SELECT org_id, status, subscription_mode, trial_ends_at, period_starts_at, + period_ends_at, included_usage_micros + FROM organization_entitlements`, + ); + expect(first.rows).toHaveLength(1); + expect(first.rows[0]).toMatchObject({ + status: "trialing", + subscription_mode: "hosted", + included_usage_micros: "100000000", + }); + expect(first.rows[0]!.trial_ends_at.getTime() - first.rows[0]!.period_starts_at.getTime()) + .toBe(30 * 24 * 60 * 60 * 1_000); + expect(first.rows[0]!.period_ends_at).toEqual(first.rows[0]!.trial_ends_at); + + const alerts = await pool.query<{ payload: Record }>( + "SELECT payload FROM jobs WHERE kind = 'operator-alert'", + ); + expect(alerts.rows).toHaveLength(1); + expect(alerts.rows[0]!.payload).toMatchObject({ + event: "trial_started", + orgSlug: "newcustomer", + accountLogin: "NewCustomer", + githubOwnerId: 9090, + githubInstallationId: 8080, + }); + + expect((await post("installation", { + action: "deleted", + installation: { id: 8080, account }, + }, "trial-deleted-1")).status).toBe(200); + expect((await post("installation", { + ...created, + installation: { id: 8081, account, suspended_at: null }, + }, "trial-created-2")).status).toBe(200); + + const afterReinstall = await pool.query<{ + trial_ends_at: Date; + alert_count: number; + }>( + `SELECT entitlement.trial_ends_at, + (SELECT count(*)::int FROM jobs WHERE kind = 'operator-alert') AS alert_count + FROM organization_entitlements entitlement`, + ); + expect(afterReinstall.rows).toHaveLength(1); + expect(afterReinstall.rows[0]!.trial_ends_at).toEqual(first.rows[0]!.trial_ends_at); + expect(afterReinstall.rows[0]!.alert_count).toBe(1); + }); + + test("suspended installation waits to grant its trial until unsuspended", async () => { + const account = { id: 9191, login: "PausedCustomer", type: "Organization" }; + expect((await post("installation", { + action: "created", + installation: { id: 8181, account, suspended_at: "2026-07-18T00:00:00Z" }, + repositories: [], + }, "trial-suspended-created")).status).toBe(200); + expect((await pool.query("SELECT 1 FROM organization_entitlements")).rowCount).toBe(0); + + expect((await post("installation", { + action: "unsuspend", + installation: { id: 8181, account, suspended_at: null }, + }, "trial-unsuspended")).status).toBe(200); + expect((await pool.query("SELECT 1 FROM organization_entitlements")).rowCount).toBe(1); + expect((await pool.query("SELECT 1 FROM jobs WHERE kind = 'operator-alert'")).rowCount).toBe(1); + }); + + test("hosted pause starts a BYOK trial without consuming hosted inference", async () => { + process.env.POSTIL_HOSTED_INFERENCE_ENABLED = "0"; + expect((await post("installation", { + action: "created", + installation: { + id: 8282, + account: { id: 9292, login: "WaitingCustomer", type: "Organization" }, + suspended_at: null, + }, + repositories: [], + }, "trial-hosted-paused")).status).toBe(200); + const entitlement = await pool.query<{ subscription_mode: string }>( + "SELECT subscription_mode FROM organization_entitlements", + ); + expect(entitlement.rows).toEqual([{ subscription_mode: "byok" }]); + expect((await pool.query("SELECT 1 FROM jobs WHERE kind = 'operator-alert'")).rowCount).toBe(1); + }); + + test("trial setup does not enqueue email when operator alerts are not configured", async () => { + delete process.env.POSTIL_OPERATOR_ALERT_EMAIL; + expect((await post("installation", { + action: "created", + installation: { + id: 8383, + account: { id: 9393, login: "SelfHostedCustomer", type: "Organization" }, + suspended_at: null, + }, + repositories: [], + }, "trial-without-operator-alerts")).status).toBe(200); + expect((await pool.query("SELECT 1 FROM organization_entitlements")).rowCount).toBe(1); + expect((await pool.query("SELECT 1 FROM jobs WHERE kind = 'operator-alert'")).rowCount).toBe(0); + }); + test("pull_request synchronize neutralizes superseded review check-runs", async () => { const orgId = await seedOrg(); const inst = await seedInstallation(orgId, 250); diff --git a/tests/worker-runner.test.ts b/tests/worker-runner.test.ts index a83e4924..3a6f04e2 100644 --- a/tests/worker-runner.test.ts +++ b/tests/worker-runner.test.ts @@ -19,6 +19,7 @@ let respondDeliveryRun: (() => Promise) | undefined; let respondFailureCommentRun: (() => Promise) | undefined; let webhookCommentRun: (() => Promise) | undefined; let billingContactVerificationRun: (() => Promise) | undefined; +let operatorAlertRun: (() => Promise) | undefined; let gateStateSyncRun: (() => Promise) | undefined; let cleanupRun: (() => Promise) | undefined; let webhookDeliveryLoadError: Error | undefined; @@ -126,6 +127,12 @@ mock.module("@/worker/billing-contact-verification", () => ({ }, })); +mock.module("@/worker/operator-alert", () => ({ + runOperatorAlertJob: async () => { + await operatorAlertRun?.(); + }, +})); + mock.module("@/worker/gate-state-sync", () => ({ runGateStateSyncJob: async () => { await gateStateSyncRun?.(); @@ -168,6 +175,7 @@ beforeEach(() => { respondFailureCommentRun = async () => undefined; webhookCommentRun = async () => undefined; billingContactVerificationRun = async () => undefined; + operatorAlertRun = async () => undefined; gateStateSyncRun = async () => undefined; cleanupRun = async () => undefined; webhookDeliveryLoadError = undefined; @@ -300,6 +308,7 @@ describe("drainQueueOnce", () => { "respond", "respond-delivery", "billing-contact-verification", + "operator-alert", "gate-state-sync", "check-run-cleanup", "respond-failure-comment", @@ -389,6 +398,21 @@ describe("drainQueueOnce", () => { expect(completed).toEqual([1]); }); + test("dispatches durable operator alert jobs", async () => { + const job = reviewJob(1); + job.kind = "operator-alert"; + job.payload = { event: "trial_started", orgId: 7 }; + let called = false; + operatorAlertRun = async () => { + called = true; + }; + jobs.push(job); + + expect(await drainQueueOnce("test-drain", { maxJobs: 1 })).toBe(1); + expect(called).toBe(true); + expect(completed).toEqual([1]); + }); + test("dispatches durable gate state synchronization jobs", async () => { const job = reviewJob(1); job.kind = "gate-state-sync"; From f603eca97a31168e9393db810670c7acf07fc090 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Sat, 18 Jul 2026 14:45:38 +0000 Subject: [PATCH 2/3] Update hosted pause dashboard verification --- scripts/verify-dashboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-dashboard.ts b/scripts/verify-dashboard.ts index 456dd16c..03fd0127 100644 --- a/scripts/verify-dashboard.ts +++ b/scripts/verify-dashboard.ts @@ -183,7 +183,7 @@ try { "Organization settings", "Config files", "Hosted by Postil", - "Managed reviews are paused.", + "Hosted inference is paused.", "acme/unreached", "pending", "set up but not yet exercised", From 230d26884deac1a50146672953c3fdeddfd7f4e1 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Sat, 18 Jul 2026 14:49:56 +0000 Subject: [PATCH 3/3] Validate operator alert dashboard URLs --- src/lib/env.ts | 26 ++++++++++++++++++-------- tests/env-validation.test.ts | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/lib/env.ts b/src/lib/env.ts index 5b159580..ecd0825d 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -418,14 +418,7 @@ export function validateEnv(processKind: "web" | "worker"): void { ); } if (processKind === "web" && process.env.POSTIL_PUBLIC_URL?.trim()) { - try { - configuredPublicOrigin(); - } catch (error) { - const detail = safePublicUrlValidationDetail(error); - throw new Error( - `Postil web cannot start: invalid POSTIL_PUBLIC_URL.${detail ? ` ${detail}` : ""}`, - ); - } + validateConfiguredPublicOrigin(processKind); } if ( processKind === "worker" && @@ -454,6 +447,23 @@ function validateOperatorAlertEnv(processKind: "web" | "worker"): void { `Postil ${processKind} cannot start: POSTIL_OPERATOR_ALERT_EMAIL requires BREVO_API_KEY.`, ); } + if (!process.env.POSTIL_PUBLIC_URL?.trim()) { + throw new Error( + `Postil ${processKind} cannot start: POSTIL_OPERATOR_ALERT_EMAIL requires POSTIL_PUBLIC_URL.`, + ); + } + validateConfiguredPublicOrigin(processKind); +} + +function validateConfiguredPublicOrigin(processKind: "web" | "worker"): void { + try { + configuredPublicOrigin(); + } catch (error) { + const detail = safePublicUrlValidationDetail(error); + throw new Error( + `Postil ${processKind} cannot start: invalid POSTIL_PUBLIC_URL.${detail ? ` ${detail}` : ""}`, + ); + } } function safePublicUrlValidationDetail(error: unknown): string | undefined { diff --git a/tests/env-validation.test.ts b/tests/env-validation.test.ts index 8bed7935..bff59c2b 100644 --- a/tests/env-validation.test.ts +++ b/tests/env-validation.test.ts @@ -282,11 +282,29 @@ describe("web startup environment validation", () => { expect(() => validateEnv("web")).toThrow(/requires BREVO_API_KEY/); process.env.BREVO_API_KEY = "brevo-test-key"; + delete process.env.POSTIL_PUBLIC_URL; + expect(() => validateEnv("web")).toThrow(/requires POSTIL_PUBLIC_URL/); + + process.env.POSTIL_PUBLIC_URL = "https://postil.dev"; expect(() => validateEnv("web")).not.toThrow(); process.env.POSTIL_OPERATOR_ALERT_EMAIL = "invalid"; expect(() => validateEnv("web")).toThrow(/must be a valid email address/); }); + + test("validates operator alert dashboard links in the worker", () => { + configureRequiredWorkerEnvironment(); + process.env.POSTIL_OPERATOR_ALERT_EMAIL = "operator@example.com"; + process.env.BREVO_API_KEY = "brevo-test-key"; + process.env.POSTIL_PUBLIC_URL = "https://postil.dev/tenant"; + + expect(() => validateEnv("worker")).toThrow( + /invalid POSTIL_PUBLIC_URL.*without a path, query, or fragment/, + ); + + process.env.POSTIL_PUBLIC_URL = "https://postil.dev"; + expect(() => validateEnv("worker")).not.toThrow(); + }); }); function configureRequiredWebEnvironment(): void {