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: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ The desktop app authenticates via the **OAuth 2.0 device authorization grant** (

One codebase, two deployment modes gated by `KINORA_CLOUD` (env). Self-host (`false`) unlocks every feature; cloud (`true`) enables **Polar** billing.

- **Billing** (`src/billing/`): `polar.ts` (Polar SDK + better-auth plugin), `entitlements.ts` / `usage.ts` (plan limits), `retention.ts` (per-plan run-retention windows, swept by `purge-expired-runs`).
- **Billing** (`src/billing/`): `polar.ts` (Polar SDK + better-auth plugin), `entitlements.ts` / `usage.ts` (plan limits: monthly test results, projects, and artifact bytes - the storage cap rejects an over-quota upload with a 402 and deletes the blob it just streamed), `retention.ts` (per-plan run-retention windows, swept by `purge-expired-runs`).
- **Retention** (`src/billing/retention.ts`): cloud derives windows from the plan tier and is swept by an external cron calling `purge-expired-runs`. Self-host instead reads `retentionPolicy` from env (`KINORA_ARTIFACT_RETENTION_DAYS` drops blobs but keeps runs; `KINORA_RETENTION_DAYS` / `KINORA_KEEP_LAST_RUNS` delete runs), and `src/index.ts` runs a daily in-process sweep gated on that policy being non-null, so cloud never double-sweeps.
- **Alerts** (`src/alerts/`): per-project notifications on new failures / regressions. Channels are `slack.ts`, `email.ts` (nodemailer/SMTP), `webhook.ts`, dispatched by `notify.ts` with an every-run / on-failure / on-regression policy (`core.ts`).
- **Feedback** (`src/feedback/`, `feedback` tRPC router): in-app "Send feedback" posts bug/feature reports to the private cloud task tracker. Cloud-only: `resolveFeedbackTracker` in `env.ts` returns null unless `KINORA_CLOUD=true` and all `FEEDBACK_TRACKER_*` vars are set; `config.get.feedbackEnabled` gates the web UI.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Knex } from 'knex'

// The storage quota sums an org's artifact sizes on every upload; without this it seq-scans.
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('artifact', (t) => {
t.index(['project_id'], 'artifact_projectId_idx')
})
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('artifact', (t) => {
t.dropIndex(['project_id'], 'artifact_projectId_idx')
})
}
33 changes: 30 additions & 3 deletions packages/server/src/billing/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,24 @@ export interface Entitlements {
maxProjects: number
retentionDays: number
includedResults: number
storageBytes: number
alerts: boolean
}

const GB = 1024 ** 3

const UNLIMITED: Omit<Entitlements, 'tier'> = {
maxProjects: Number.POSITIVE_INFINITY,
retentionDays: Number.POSITIVE_INFINITY,
includedResults: Number.POSITIVE_INFINITY,
storageBytes: Number.POSITIVE_INFINITY,
alerts: true,
}

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 },
free: { maxProjects: 1, retentionDays: 7, includedResults: 2_500, storageBytes: 2 * GB, alerts: false },
team: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: 90, includedResults: 10_000, storageBytes: 50 * GB, alerts: true },
pro: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: 365, includedResults: 50_000, storageBytes: 250 * GB, alerts: true },
enterprise: UNLIMITED,
selfhost: UNLIMITED,
}
Expand Down Expand Up @@ -225,3 +229,26 @@ export function ingestCapError(e: Entitlements, usedResults: number, isNewProjec

return null
}

// Artifact storage cap. `incoming` is the artifact about to be stored (0 for a pre-flight check).
export function storageCapError(e: Entitlements, usedBytes: number, incoming = 0): IngestCap | null {
if (!Number.isFinite(e.storageBytes) || usedBytes + incoming <= e.storageBytes)
return null
return {
error: `Plan artifact storage limit reached (${formatBytes(e.storageBytes)}). Older runs free up space as they expire, or upgrade for more.`,
limit: e.storageBytes,
}
}

export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes))
return 'unlimited'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
return `${Math.round(value * 10) / 10} ${units[unit]}`
}
15 changes: 13 additions & 2 deletions packages/server/src/billing/usage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { and, count, eq, gte } from 'drizzle-orm'
import { and, count, eq, gte, sum } from 'drizzle-orm'
import { db } from '../db'
import { project, run, test } from '../db/schemas/index'
import { artifact, project, run, test } from '../db/schemas/index'

export function startOfMonthUtc(): Date {
const now = new Date()
Expand All @@ -20,6 +20,17 @@ export async function currentPeriodResults(organizationId: string): Promise<numb
return row?.total ?? 0
}

// Bytes currently stored for an org. Retention purges shrink it, so it is a live total, not a period one.
export async function storageBytes(organizationId: string): Promise<number> {
const [row] = await db
.select({ total: sum(artifact.size) })
.from(artifact)
.innerJoin(project, eq(artifact.projectId, project.id))
.where(eq(project.organizationId, organizationId))

return Number(row?.total ?? 0)
}

export async function projectCount(organizationId: string): Promise<number> {
const [row] = await db
.select({ total: count() })
Expand Down
5 changes: 4 additions & 1 deletion packages/server/src/db/schemas/kinora-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ export const artifact = pgTable('artifact', {
sha1: text('sha1'),
size: integer('size'),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, table => [index('artifact_runId_idx').on(table.runId)])
}, table => [
index('artifact_runId_idx').on(table.runId),
index('artifact_projectId_idx').on(table.projectId),
])

// Cached Polar billing state, synced from the customer.state_changed webhook.
export const subscription = pgTable('subscription', {
Expand Down
18 changes: 16 additions & 2 deletions packages/server/src/public-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { and, eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { notifyRun } from '../alerts/notify'
import { getEntitlements, ingestCapError, quotaCrossing, quotaWarningText } from '../billing/entitlements'
import { getEntitlements, ingestCapError, quotaCrossing, quotaWarningText, storageCapError } from '../billing/entitlements'
import { meterTestResults, polarClient } from '../billing/polar'
import { currentPeriodResults, projectCount, startOfMonthUtc } from '../billing/usage'
import { currentPeriodResults, projectCount, startOfMonthUtc, storageBytes } from '../billing/usage'
import { db } from '../db'
import { artifact, member, project, run, test, user } from '../db/schemas/index'
import { auth } from '../lib/auth'
Expand Down Expand Up @@ -320,12 +320,26 @@ publicApi.post('/runs/:runId/artifacts', async (c) => {
if (!owner)
return c.json({ error: 'Run not found' }, 404)

// One usage read per upload, reused after streaming to judge the artifact that just landed.
const entitlements = await getEntitlements(orgId)
const usedBytes = Number.isFinite(entitlements.storageBytes) ? await storageBytes(orgId) : 0
// Probe with a single byte: no room for that means no room for anything, so skip reading the body.
const full = storageCapError(entitlements, usedBytes, 1)
if (full)
return c.json(full, 402)

const uploaded = await streamArtifact(c, r.projectId, runId)
if (!uploaded)
return c.json({ error: 'file is required' }, 400)
if ('tooLarge' in uploaded)
return c.json({ error: 'Artifact too large' }, 413)

const overCap = storageCapError(entitlements, usedBytes, uploaded.size)
if (overCap) {
await storage.delete(uploaded.key).catch(error => logger.error({ error, key: uploaded.key }, 'over-cap artifact cleanup failed'))
return c.json(overCap, 402)
}

const t = uploaded.testKey
? await db.query.test.findFirst({
where: and(eq(test.runId, runId), eq(test.testKey, uploaded.testKey)),
Expand Down
7 changes: 5 additions & 2 deletions packages/server/src/router/billing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getEntitlements, getSubscription } from '../billing/entitlements'
import { currentPeriodResults } from '../billing/usage'
import { currentPeriodResults, storageBytes } from '../billing/usage'
import { orgProcedure, router } from '../trpc/index'

// Infinity doesn't survive JSON: unlimited limits go over the wire as null.
Expand All @@ -10,10 +10,11 @@ function finiteOrNull(n: number): number | null {
export const billingRouter = router({
summary: orgProcedure.query(async ({ ctx }) => {
const organizationId = ctx.organizationId
const [entitlements, sub, usedResults] = await Promise.all([
const [entitlements, sub, usedResults, usedStorageBytes] = await Promise.all([
getEntitlements(organizationId),
getSubscription(organizationId),
currentPeriodResults(organizationId),
storageBytes(organizationId),
])

return {
Expand All @@ -23,6 +24,8 @@ export const billingRouter = router({
retentionDays: finiteOrNull(entitlements.retentionDays),
includedResults: finiteOrNull(entitlements.includedResults),
usedResults,
storageBytes: finiteOrNull(entitlements.storageBytes),
usedStorageBytes,
status: sub?.status ?? null,
currentPeriodEnd: sub?.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: sub?.cancelAtPeriodEnd ?? false,
Expand Down
36 changes: 32 additions & 4 deletions packages/server/test/ingest-cap.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Entitlements } from '../src/billing/entitlements'
import { describe, expect, it } from 'vitest'
import { ingestCapError, quotaCrossing, quotaWarningText } from '../src/billing/entitlements'
import { formatBytes, ingestCapError, quotaCrossing, quotaWarningText, storageCapError } from '../src/billing/entitlements'

const free: Entitlements = { tier: 'free', includedResults: 2500, maxProjects: 1, retentionDays: 7, alerts: false }
const selfhost: Entitlements = { tier: 'selfhost', includedResults: Infinity, maxProjects: Infinity, retentionDays: Infinity, alerts: true }
const free: Entitlements = { tier: 'free', includedResults: 2500, maxProjects: 1, retentionDays: 7, storageBytes: 2 * 1024 ** 3, alerts: false }
const selfhost: Entitlements = { tier: 'selfhost', includedResults: Infinity, maxProjects: Infinity, retentionDays: Infinity, storageBytes: Infinity, alerts: true }

describe('ingestCapError', () => {
it('blocks free tier once the monthly result limit is reached', () => {
Expand All @@ -23,12 +23,40 @@ describe('ingestCapError', () => {
})

it('the result cap only applies to free, not paid tiers', () => {
const team: Entitlements = { tier: 'team', includedResults: 10_000, maxProjects: Infinity, retentionDays: 90, alerts: true }
const team: Entitlements = { tier: 'team', includedResults: 10_000, maxProjects: Infinity, retentionDays: 90, storageBytes: 50 * 1024 ** 3, alerts: true }
// Team is metered (overage billed), never hard-capped on results.
expect(ingestCapError(team, 999_999, false, 0)).toBeNull()
})
})

describe('storageCapError', () => {
const small: Entitlements = { ...free, storageBytes: 1000 }

it('allows an artifact that still fits', () => {
expect(storageCapError(small, 900, 100)).toBeNull()
})

it('blocks the artifact that would cross the limit', () => {
expect(storageCapError(small, 900, 101)).toEqual({ error: expect.stringContaining('storage'), limit: 1000 })
})

it('blocks everything once full, including a single byte', () => {
expect(storageCapError(small, 1000, 1)).not.toBeNull()
})

it('never caps an unlimited tier', () => {
expect(storageCapError(selfhost, 1e15, 1e9)).toBeNull()
})
})

describe('formatBytes', () => {
it('scales to the largest fitting unit', () => {
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(2 * 1024 ** 3)).toBe('2 GB')
expect(formatBytes(1536)).toBe('1.5 KB')
})
})

describe('quotaCrossing', () => {
const LIMIT = 2500 // near = 2000

Expand Down
14 changes: 14 additions & 0 deletions packages/server/test/retention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { resolve } from 'node:path'
import { eq } from 'drizzle-orm'
import { beforeEach, describe, expect, it } from 'vitest'
import { purgeArtifactsBefore, purgeBeyondLastRuns, purgeExpiredRuns, purgeScope } from '../src/billing/retention'
import { storageBytes } from '../src/billing/usage'
import { db } from '../src/db'
import { artifact, project, run } from '../src/db/schemas/index'
import { env } from '../src/lib/env'
Expand Down Expand Up @@ -156,6 +157,19 @@ describe('purgeArtifactsBefore', () => {
})
})

describe('retention and the storage quota', () => {
it('frees quota: purged artifacts stop counting against the org', async () => {
const u = await createUser()
const org = await ownedOrgId(u.id)
const runId = await seedRun(u.id, new Date(Date.now() - 100 * DAY))
await seedArtifact(runId, 'trace.zip')
expect(await storageBytes(org)).toBe(3)

await purgeScope(new Date(), {})
expect(await storageBytes(org)).toBe(0)
})
})

describe('purgeExpiredRuns', () => {
it('is a no-op on self-host with no retention policy set', async () => {
const a = await createUser()
Expand Down
72 changes: 72 additions & 0 deletions packages/server/test/storage-cap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Entitlements } from '../src/billing/entitlements'
import { existsSync } from 'node:fs'
import { readdir } from 'node:fs/promises'
import { resolve } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'

// Tests run self-host (unlimited), so pin a tiny cap and re-import the graph with it.
const CAP = 5
const capped: Entitlements = { tier: 'free', maxProjects: 1, retentionDays: 7, includedResults: 2500, storageBytes: CAP, alerts: false }

vi.mock('../src/billing/entitlements', async importOriginal => ({
...(await importOriginal<typeof import('../src/billing/entitlements')>()),
getEntitlements: async () => capped,
}))
vi.resetModules()

const { app } = await import('../src/app')
const { db } = await import('../src/db')
const { env } = await import('../src/lib/env')
const { createApiKey, createUser, ingest, resetDb } = await import('./helpers')

beforeEach(resetDb)

async function upload(runId: string, apiKey: string, bytes: number) {
const form = new FormData()
form.set('file', new File([new Uint8Array(bytes)], 'trace.zip'))
form.set('name', 'trace')
return app.request(`/api/v1/runs/${runId}/artifacts`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
})
}

async function seedRun(): Promise<{ runId: string, apiKey: string }> {
const user = await createUser()
const apiKey = await createApiKey(user.id)
await ingest(apiKey)
return { runId: (await db.query.run.findMany())[0].id, apiKey }
}

describe('artifact storage cap', () => {
it('accepts uploads up to the plan limit', async () => {
const { runId, apiKey } = await seedRun()
expect((await upload(runId, apiKey, 4)).status).toBe(201)
expect(await db.query.artifact.findMany()).toHaveLength(1)
})

it('rejects the upload that would cross the limit and keeps no orphan blob', async () => {
const { runId, apiKey } = await seedRun()
expect((await upload(runId, apiKey, 4)).status).toBe(201)

const res = await upload(runId, apiKey, 4) // 4 + 4 > 5
expect(res.status).toBe(402)
expect(await res.json()).toMatchObject({ limit: CAP, error: expect.stringContaining('storage') })

const stored = await db.query.artifact.findMany()
expect(stored).toHaveLength(1) // only the first upload survived
expect(existsSync(resolve(env.STORAGE_DIR, stored[0].storageKey))).toBe(true)
// The rejected bytes left nothing behind: the run prefix holds just the accepted blob.
const runDir = resolve(env.STORAGE_DIR, stored[0].storageKey, '..')
expect(await readdir(runDir)).toHaveLength(1)
})

it('rejects without reading the body once the workspace is full', async () => {
const { runId, apiKey } = await seedRun()
expect((await upload(runId, apiKey, 5)).status).toBe(201)

expect((await upload(runId, apiKey, 1)).status).toBe(402)
expect(await db.query.artifact.findMany()).toHaveLength(1)
})
})
37 changes: 35 additions & 2 deletions packages/server/test/usage.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { randomUUID } from 'node:crypto'
import { beforeEach, describe, expect, it } from 'vitest'
import { currentPeriodResults, projectCount } from '../src/billing/usage'
import { currentPeriodResults, projectCount, storageBytes } from '../src/billing/usage'
import { db } from '../src/db'
import { project, run, test as testRow } from '../src/db/schemas/index'
import { artifact, project, run, test as testRow } from '../src/db/schemas/index'
import { createUser, ownedOrgId, resetDb } from './helpers'

beforeEach(resetDb)
Expand Down Expand Up @@ -89,3 +89,36 @@ describe('projectCount', () => {
expect(await projectCount(await ownedOrgId(b.id))).toBe(0)
})
})

describe('storageBytes', () => {
async function seedArtifact(userId: string, size: number): Promise<void> {
await seedTests(userId, 1)
const r = (await db.query.run.findMany()).at(-1)!
await db.insert(artifact).values({
id: randomUUID(),
projectId: r.projectId,
runId: r.id,
name: 'trace',
contentType: 'application/zip',
storageKey: `${r.projectId}/${r.id}/trace.zip`,
size,
})
}

it('sums the artifact sizes of the org', async () => {
const user = await createUser()
const org = await ownedOrgId(user.id)
expect(await storageBytes(org)).toBe(0)

await seedArtifact(user.id, 300)
await seedArtifact(user.id, 700)
expect(await storageBytes(org)).toBe(1000)
})

it('ignores another org artifacts', async () => {
const a = await createUser('a@test.dev')
const b = await createUser('b@test.dev')
await seedArtifact(a.id, 500)
expect(await storageBytes(await ownedOrgId(b.id))).toBe(0)
})
})
Loading