Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion scripts/verify-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions src/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -81,9 +81,10 @@ export default function DocsIndexPage() {
<div>
<h1 className="serif-display text-4xl">Documentation</h1>
<p className="prose-postil mt-4 text-lg">
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.
</p>
<div className="mt-10 grid gap-5 sm:grid-cols-2">
{CARDS.map((card) => (
Expand Down
11 changes: 6 additions & 5 deletions src/app/docs/quickstart/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,17 @@ export default function QuickstartPage() {
<div className="prose-postil">
<h1 className="serif-display text-4xl text-charcoal">Quickstart</h1>
<p className="mt-4 text-lg">
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.
</p>

<h2>1. GitHub App with BYOK</h2>
<p>
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.
</p>
<p>
Draft pull requests are skipped until marked ready. Existing open pull
Expand Down
6 changes: 4 additions & 2 deletions src/app/docs/self-hosted/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,13 @@ docker compose exec web bun run db:migrate`}</code>
to run.
</li>
<li>
<code>BREVO_API_KEY</code> enables billing-contact verification email.
<code>BREVO_API_KEY</code> enables transactional email.
The sender defaults to{" "}
<code>reviews@mail.postil.dev</code> and can be changed with{" "}
<code>POSTIL_EMAIL_FROM_EMAIL</code> and{" "}
<code>POSTIL_EMAIL_FROM_NAME</code>.
<code>POSTIL_EMAIL_FROM_NAME</code>. Set{" "}
<code>POSTIL_OPERATOR_ALERT_EMAIL</code> to a verified operator inbox
for one-time trial signup alerts.
</li>
</ul>

Expand Down
7 changes: 4 additions & 3 deletions src/app/install/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -28,8 +28,9 @@ export default function InstallPage() {
Choose your forge. Start reviewing pull requests.
</h1>
<p className="mt-6 max-w-2xl text-lg text-ink-soft">
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.
</p>

<div className="mt-10">
Expand Down
172 changes: 117 additions & 55 deletions src/app/orgs/[slug]/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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." };
Expand All @@ -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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -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." };
Expand Down
Loading
Loading