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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ jobs:
GOOGLE_CLIENT_SECRET: ''
GITHUB_CLIENT_ID: ''
GITHUB_CLIENT_SECRET: ''
KINORA_CLOUD: 'false'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/kinora.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import process from 'node:process'
import { parseArgs } from 'node:util'
import { IngestError } from '@kinora/core'
import { uploadReport } from './upload'

const USAGE = `kinora - upload a Playwright json report to a kinora server
Expand Down Expand Up @@ -92,6 +93,9 @@ async function main(): Promise<void> {
}

main().catch((err) => {
console.error(err)
if (err instanceof IngestError)
console.error(`error: ${err.message}`)
else
console.error(err)
process.exit(1)
})
28 changes: 26 additions & 2 deletions packages/core/src/lib/ingest-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,30 @@ export interface UploadArtifactInput {
body: Uint8Array | Blob
}

export class IngestError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'IngestError'
this.status = status
}
}

// Prefer the server's JSON `error` field (human-readable, e.g. plan-limit messages).
async function toIngestError(res: Response, fallback: string): Promise<IngestError> {
const text = await res.text()
let message = text || fallback
try {
const body = JSON.parse(text) as { error?: unknown }
if (typeof body.error === 'string')
message = body.error
}
catch {
// non-JSON body: keep the raw text / fallback
}
return new IngestError(res.status, message)
}

export function createIngestClient(opts: IngestClientOptions) {
const base = opts.baseUrl.replace(/\/+$/, '')
const doFetch = opts.fetch ?? globalThis.fetch
Expand All @@ -61,7 +85,7 @@ export function createIngestClient(opts: IngestClientOptions) {
body: JSON.stringify(input),
})
if (!res.ok)
throw new Error(`kinora ingest failed: ${res.status} ${await res.text()}`)
throw await toIngestError(res, `kinora ingest failed (${res.status})`)
return ingestRunResultSchema.parse(await res.json())
},

Expand All @@ -79,7 +103,7 @@ export function createIngestClient(opts: IngestClientOptions) {
body: form,
})
if (!res.ok)
throw new Error(`kinora artifact upload failed: ${res.status} ${await res.text()}`)
throw await toIngestError(res, `kinora artifact upload failed (${res.status})`)
return uploadArtifactResultSchema.parse(await res.json())
},
}
Expand Down
44 changes: 44 additions & 0 deletions packages/core/test/ingest-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { IngestRun } from '../src/index'
import { describe, expect, it } from 'vitest'
import { createIngestClient, IngestError } from '../src/index'

const PAYLOAD: IngestRun = {
project: { slug: 'web-app', name: 'web-app' },
run: {
startedAt: '2026-01-01T00:00:00.000Z',
duration: 0,
counts: { total: 0, expected: 0, unexpected: 0, flaky: 0, skipped: 0 },
},
tests: [],
}

function clientWith(response: Response) {
return createIngestClient({ baseUrl: 'http://test', token: 't', fetch: async () => response })
}

describe('createIngestClient.uploadRun', () => {
it('surfaces the server error message on 402', async () => {
const client = clientWith(
new Response(JSON.stringify({ error: 'Free plan limit reached. Upgrade.', limit: 2500 }), { status: 402 }),
)
const err = await client.uploadRun(PAYLOAD).catch((e: unknown) => e)
expect(err).toBeInstanceOf(IngestError)
expect((err as IngestError).status).toBe(402)
expect((err as IngestError).message).toBe('Free plan limit reached. Upgrade.')
})

it('falls back to the raw body when it is not JSON', async () => {
const client = clientWith(new Response('upstream exploded', { status: 500 }))
const err = await client.uploadRun(PAYLOAD).catch((e: unknown) => e)
expect(err).toBeInstanceOf(IngestError)
expect((err as IngestError).status).toBe(500)
expect((err as IngestError).message).toBe('upstream exploded')
})

it('returns the parsed result on success', async () => {
const client = clientWith(
new Response(JSON.stringify({ projectId: 'p1', runId: 'r1', tests: 3 }), { status: 201 }),
)
await expect(client.uploadRun(PAYLOAD)).resolves.toEqual({ projectId: 'p1', runId: 'r1', tests: 3 })
})
})
2 changes: 1 addition & 1 deletion packages/core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
"lib": ["ES2023"],
"types": ["node"]
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "test/**/*.ts"]
}
7 changes: 5 additions & 2 deletions packages/reporter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { CiMeta, Counts, GitMeta, IngestRun, NormTest } from '@kinora/core'
import type { FullConfig, FullResult, Reporter, Suite, TestCase } from '@playwright/test/reporter'
import { readFile } from 'node:fs/promises'
import process from 'node:process'
import { createIngestClient, isTraceAttachment, makeTestKey } from '@kinora/core'
import { createIngestClient, IngestError, isTraceAttachment, makeTestKey } from '@kinora/core'

export interface KinoraReporterOptions {
/** kinora server base URL. Defaults to env KINORA_URL. */
Expand Down Expand Up @@ -144,7 +144,10 @@ export default class KinoraReporter implements Reporter {
console.log(`[kinora] uploaded ${res.tests} tests + ${traces} traces (run ${res.runId})`)
}
catch (err) {
console.error(`[kinora] upload failed:`, err instanceof Error ? err.message : err)
if (err instanceof IngestError && err.status === 402)
console.warn(`[kinora] ${err.message}`)
else
console.error(`[kinora] upload failed:`, err instanceof Error ? err.message : err)
}
}
}
11 changes: 10 additions & 1 deletion packages/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,13 @@ GOOGLE_CLIENT_SECRET=

# GitHub
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_CLIENT_SECRET=

# Cloud mode: true enables Polar billing. Self-host leaves this false
KINORA_CLOUD=false

# Polar (required only when KINORA_CLOUD=true)
POLAR_ACCESS_TOKEN=
POLAR_WEBHOOK_SECRET=
POLAR_PRODUCT_TEAM_ID=
POLAR_PRODUCT_PRO_ID=
5 changes: 4 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,17 @@
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:seed": "tsx scripts/seed.ts",
"db:seed:market": "tsx scripts/seed-market.ts"
"db:seed:market": "tsx scripts/seed-market.ts",
"purge-expired-runs": "tsx scripts/purge-expired-runs.ts"
},
"dependencies": {
"@better-auth/api-key": "^1.5.6",
"@hono/node-server": "^2.0.4",
"@hono/trpc-server": "^0.4.2",
"@hono/zod-validator": "^0.7.6",
"@kinora/core": "workspace:*",
"@polar-sh/better-auth": "^1.8.4",
"@polar-sh/sdk": "^0.47.1",
"@trpc/server": "^11.16.0",
"better-auth": "^1.5.6",
"dotenv": "^17.3.1",
Expand Down
13 changes: 13 additions & 0 deletions packages/server/scripts/purge-expired-runs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import process from 'node:process'
import { purgeExpiredRuns } from '../src/billing/retention'
import { logger } from '../src/lib/logger'

purgeExpiredRuns(new Date())
.then(({ deleted }) => {
logger.info({ deleted }, 'purge-expired-runs complete')
process.exit(0)
})
.catch((error) => {
logger.error({ error }, 'purge-expired-runs failed')
process.exit(1)
})
98 changes: 98 additions & 0 deletions packages/server/src/billing/entitlements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { eq } from 'drizzle-orm'
import { db } from '../db'
import { subscription } from '../db/schemas/index'
import { cloud, env } from '../lib/env'

export type Tier = 'free' | 'team' | 'pro' | 'enterprise' | 'selfhost'

export interface Entitlements {
tier: Tier
maxProjects: number
retentionDays: number
includedResults: number
alerts: boolean
}

const LIMITS: Record<Tier, Omit<Entitlements, 'tier'>> = {
free: { maxProjects: 1, retentionDays: 7, includedResults: 2_500, alerts: false },
team: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: 90, includedResults: 10_000, alerts: true },
pro: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: 365, includedResults: 50_000, alerts: true },
enterprise: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: Number.POSITIVE_INFINITY, includedResults: Number.POSITIVE_INFINITY, alerts: true },
selfhost: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: Number.POSITIVE_INFINITY, includedResults: Number.POSITIVE_INFINITY, alerts: true },
}

export function retentionDaysFor(tier: Tier): number {
return LIMITS[tier].retentionDays
}

function tierForProduct(productId: string | undefined): 'free' | 'team' | 'pro' {
if (!productId)
return 'free'
if (productId === env.POLAR_PRODUCT_TEAM_ID)
return 'team'
if (productId === env.POLAR_PRODUCT_PRO_ID)
return 'pro'
return 'free'
}

interface CustomerStateInput {
userId: string | null | undefined
polarCustomerId: string
subscriptions: Array<{
productId: string
status: string
currentPeriodEnd: Date | null
cancelAtPeriodEnd: boolean
}>
}

export async function syncCustomerState(state: CustomerStateInput): Promise<void> {
if (!state.userId)
return

const sub = state.subscriptions.find(s => tierForProduct(s.productId) !== 'free')
const tier = tierForProduct(sub?.productId)

const values = {
polarCustomerId: state.polarCustomerId,
tier,
status: sub?.status ?? null,
productId: sub?.productId ?? null,
currentPeriodEnd: sub?.currentPeriodEnd ?? null,
cancelAtPeriodEnd: sub?.cancelAtPeriodEnd ?? false,
}

await db
.insert(subscription)
.values({ userId: state.userId, ...values })
.onConflictDoUpdate({ target: subscription.userId, set: values })
}

export interface SubscriptionState {
status: string | null
currentPeriodEnd: Date | null
cancelAtPeriodEnd: boolean
}

export async function getSubscription(userId: string): Promise<SubscriptionState | null> {
if (!cloud)
return null

const row = await db.query.subscription.findFirst({
where: eq(subscription.userId, userId),
columns: { status: true, currentPeriodEnd: true, cancelAtPeriodEnd: true },
})
return row ?? null
}

export async function getEntitlements(userId: string): Promise<Entitlements> {
if (!cloud)
return { tier: 'selfhost', ...LIMITS.selfhost }

const row = await db.query.subscription.findFirst({
where: eq(subscription.userId, userId),
columns: { tier: true },
})
const tier = row?.tier ?? 'free'
return { tier, ...LIMITS[tier] }
}
53 changes: 53 additions & 0 deletions packages/server/src/billing/polar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { checkout, polar, portal, usage, webhooks } from '@polar-sh/better-auth'
import { Polar } from '@polar-sh/sdk'
import { cloud, env } from '../lib/env'
import { logger } from '../lib/logger'
import { getTrustedOrigins } from '../lib/utils'
import { syncCustomerState } from './entitlements'

export const polarClient = cloud
? new Polar({
accessToken: cloud.accessToken,
server: env.NODE_ENV === 'production' ? 'production' : 'sandbox',
})
: null

export function polarAuthPlugin() {
if (!cloud || !polarClient)
return null

return polar({
client: polarClient,
createCustomerOnSignUp: true,
use: [
checkout({
products: [
{ productId: cloud.teamProductId, slug: 'team' },
{ productId: cloud.proProductId, slug: 'pro' },
],
successUrl: `${getTrustedOrigins()[0] ?? ''}/settings?checkout=success`,
authenticatedUsersOnly: true,
}),
portal(),
usage(),
webhooks({
secret: cloud.webhookSecret,
onCustomerStateChanged: async ({ data }) => {
await syncCustomerState({
userId: data.externalId,
polarCustomerId: data.id,
subscriptions: data.activeSubscriptions.map(s => ({
productId: s.productId,
status: s.status,
currentPeriodEnd: s.currentPeriodEnd,
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
})),
})
},
onPayload: async (payload) => {
logger.info({ event: payload.type }, 'polar webhook')
},
}),
],
})
}
Loading