diff --git a/.env.development b/.env.development index 39b25125c..e9029f539 100644 --- a/.env.development +++ b/.env.development @@ -71,3 +71,5 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection NODE_ENV=development DEBUG_WRITE_CHAT_MESSAGES_TO_FILE=true + +SOURCEBOT_LIGHTHOUSE_URL=http://localhost:3003 diff --git a/CHANGELOG.md b/CHANGELOG.md index 483761848..458b5b154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Redesigned the app layout with a new collapsible sidebar navigation, replacing the previous top navigation bar. [#1097](https://github.com/sourcebot-dev/sourcebot/pull/1097) +- Expired offline license keys no longer crash the process. An expired key now degrades to the unlicensed state. [#1109](https://github.com/sourcebot-dev/sourcebot/pull/1109) ## [4.16.9] - 2026-04-15 diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index b1a4b602e..1eccf3e11 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -1025,8 +1025,7 @@ "type": "string", "enum": [ "OWNER", - "MEMBER", - "GUEST" + "MEMBER" ] }, "createdAt": { diff --git a/docs/docs.json b/docs/docs.json index 50e9e7990..a193e07cc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -126,6 +126,7 @@ ] }, "docs/license-key", + "docs/billing", "docs/configuration/transactional-emails", "docs/configuration/structured-logging", "docs/configuration/audit-logs" diff --git a/docs/docs/billing.mdx b/docs/docs/billing.mdx new file mode 100644 index 000000000..96cd0c120 --- /dev/null +++ b/docs/docs/billing.mdx @@ -0,0 +1,38 @@ +--- +title: Billing +sidebarTitle: Billing +--- + +Sourcebot Enterprise is available on monthly and yearly plans. Both are seat-based. This page explains how seats are billed, how changes mid-term are handled, and what happens at renewal. + +## Seat count +Your seat count is the number of active users in your Sourcebot instance. Seat usage is reported to Sourcebot on a daily interval, and your subscription quantity is kept in sync with that number. + +## Monthly plans +Monthly subscriptions are billed at the start of each billing cycle. Users added mid-cycle are prorated across the remaining days and appear on your next invoice. Users removed mid-cycle take effect at the next cycle. There is no refund for the remainder of the current one. + +In short: you can scale up at any time and pay the prorated difference. Scaling down is effectively free until the cycle rolls over. + +## Yearly plans + +Yearly subscriptions are billed upfront for a committed seat count. As users are added during the term, the seat count rises but you aren't charged immediately. Every three months we reconcile. Any seats added that quarter are billed, prorated across the quarters remaining in the term. + +Seats only move upward during the term. Shrinking the user count does not refund, and does not reduce the seat count until renewal. At renewal, you're invoiced at your current seat count, and that number becomes the committed baseline for the next year. + +### Example + +Suppose you start a yearly plan in January with 100 seats. + +- In Q1, your user count grows to 110. At the end of Q1, you're invoiced for 10 seats prorated across the 3 remaining quarters. +- In Q2, your user count stays at 110. No reconciliation invoice is generated. +- In Q3, your user count grows to 120. At the end of Q3, you're invoiced for 10 seats prorated across the 1 remaining quarter. +- In Q4, reconciliation does not generate a charge (there are no remaining quarters to prorate across). +- At renewal in January, you're invoiced at 120 seats for the next year. 120 becomes the new committed baseline. + +## Cancellation + +Cancelling a subscription takes effect at the end of the current billing cycle (monthly) or term (yearly). You retain access to Sourcebot Enterprise features until that point. + +## Questions? + +For billing questions, [contact us](mailto:support@sourcebot.dev). diff --git a/docs/docs/license-key.mdx b/docs/docs/license-key.mdx index e1f565b0b..ecc4eebda 100644 --- a/docs/docs/license-key.mdx +++ b/docs/docs/license-key.mdx @@ -45,4 +45,6 @@ docker run \ ## Questions? -If you have any questions regarding licensing, please [contact us](https://www.sourcebot.dev/contact). \ No newline at end of file +For how seats are priced and reconciled across billing cycles, see [Billing](/docs/billing). + +For any other licensing questions, please [contact us](https://www.sourcebot.dev/contact). \ No newline at end of file diff --git a/packages/backend/src/__mocks__/prisma.ts b/packages/backend/src/__mocks__/prisma.ts new file mode 100644 index 000000000..7a07a85b4 --- /dev/null +++ b/packages/backend/src/__mocks__/prisma.ts @@ -0,0 +1,7 @@ +import { vi } from 'vitest'; + +export const prisma = { + license: { + findUnique: vi.fn().mockResolvedValue(null), + }, +}; diff --git a/packages/backend/src/api.ts b/packages/backend/src/api.ts index 0df9ee20a..fda71ac44 100644 --- a/packages/backend/src/api.ts +++ b/packages/backend/src/api.ts @@ -1,5 +1,6 @@ import { PrismaClient, RepoIndexingJobType } from '@sourcebot/db'; -import { createLogger, env, hasEntitlement, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from '@sourcebot/shared'; +import { createLogger, env, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from '@sourcebot/shared'; +import { hasEntitlement } from './entitlements.js'; import express, { Request, Response } from 'express'; import 'express-async-errors'; import * as http from "http"; @@ -100,7 +101,7 @@ export class Api { } private async triggerAccountPermissionSync(req: Request, res: Response) { - if (env.PERMISSION_SYNC_ENABLED !== 'true' || !hasEntitlement('permission-syncing')) { + if (env.PERMISSION_SYNC_ENABLED !== 'true' || !await hasEntitlement('permission-syncing')) { res.status(403).json({ error: 'Permission syncing is not enabled.' }); return; } diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index f1bfe9d05..9b88cd5ce 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -1,6 +1,7 @@ import * as Sentry from "@sentry/node"; import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db"; -import { env, hasEntitlement, createLogger, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; +import { env, createLogger, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; +import { hasEntitlement } from "../entitlements.js"; import { ensureFreshAccountToken } from "./tokenRefresh.js"; import { Job, Queue, Worker } from "bullmq"; import { Redis } from "ioredis"; @@ -50,8 +51,8 @@ export class AccountPermissionSyncer { this.worker.on('failed', this.onJobFailed.bind(this)); } - public startScheduler() { - if (!hasEntitlement('permission-syncing')) { + public async startScheduler() { + if (!await hasEntitlement('permission-syncing')) { throw new Error('Permission syncing is not supported in current plan.'); } diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index a6f42bf17..befb8593e 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -1,7 +1,8 @@ import * as Sentry from "@sentry/node"; import { PermissionSyncSource, PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db"; import { createLogger, PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "@sourcebot/shared"; -import { env, hasEntitlement } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; +import { hasEntitlement } from "../entitlements.js"; import { Job, Queue, Worker } from 'bullmq'; import { Redis } from 'ioredis'; import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js"; @@ -44,8 +45,8 @@ export class RepoPermissionSyncer { this.worker.on('failed', this.onJobFailed.bind(this)); } - public startScheduler() { - if (!hasEntitlement('permission-syncing')) { + public async startScheduler() { + if (!await hasEntitlement('permission-syncing')) { throw new Error('Permission syncing is not supported in current plan.'); } diff --git a/packages/backend/src/ee/syncSearchContexts.test.ts b/packages/backend/src/ee/syncSearchContexts.test.ts index bfd2f8b1f..9aa1decfd 100644 --- a/packages/backend/src/ee/syncSearchContexts.test.ts +++ b/packages/backend/src/ee/syncSearchContexts.test.ts @@ -12,12 +12,15 @@ vi.mock('@sourcebot/shared', async (importOriginal) => { error: vi.fn(), debug: vi.fn(), })), - hasEntitlement: vi.fn(() => true), - getPlan: vi.fn(() => 'enterprise'), SOURCEBOT_SUPPORT_EMAIL: 'support@sourcebot.dev', }; }); +vi.mock('../entitlements.js', () => ({ + hasEntitlement: vi.fn(() => Promise.resolve(true)), + getPlan: vi.fn(() => Promise.resolve('enterprise')), +})); + import { syncSearchContexts } from './syncSearchContexts.js'; // Helper to build a repo record with GitLab topics stored in metadata. diff --git a/packages/backend/src/ee/syncSearchContexts.ts b/packages/backend/src/ee/syncSearchContexts.ts index cd745a356..e186a777a 100644 --- a/packages/backend/src/ee/syncSearchContexts.ts +++ b/packages/backend/src/ee/syncSearchContexts.ts @@ -1,7 +1,8 @@ import micromatch from "micromatch"; import { createLogger } from "@sourcebot/shared"; import { PrismaClient } from "@sourcebot/db"; -import { getPlan, hasEntitlement, repoMetadataSchema, SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared"; +import { repoMetadataSchema, SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared"; +import { hasEntitlement } from "../entitlements.js"; import { SearchContext } from "@sourcebot/schemas/v3/index.type"; const logger = createLogger('sync-search-contexts'); @@ -15,10 +16,9 @@ interface SyncSearchContextsParams { export const syncSearchContexts = async (params: SyncSearchContextsParams) => { const { contexts, orgId, db } = params; - if (!hasEntitlement("search-contexts")) { + if (!await hasEntitlement("search-contexts")) { if (contexts) { - const plan = getPlan(); - logger.warn(`Skipping search context sync. Reason: "Search contexts are not supported in your current plan: ${plan}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}."`); + logger.warn(`Skipping search context sync. Reason: "Search contexts are not supported in your current plan. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}."`); } return false; } diff --git a/packages/backend/src/entitlements.ts b/packages/backend/src/entitlements.ts new file mode 100644 index 000000000..7959cd79b --- /dev/null +++ b/packages/backend/src/entitlements.ts @@ -0,0 +1,23 @@ +import { + Entitlement, + _hasEntitlement, + _getEntitlements, +} from "@sourcebot/shared"; +import { prisma } from "./prisma.js"; +import { SINGLE_TENANT_ORG_ID } from "./constants.js"; + +const getLicense = async () => { + return prisma.license.findUnique({ + where: { orgId: SINGLE_TENANT_ORG_ID }, + }); +} + +export const hasEntitlement = async (entitlement: Entitlement): Promise => { + const license = await getLicense(); + return _hasEntitlement(entitlement, license); +} + +export const getEntitlements = async (): Promise => { + const license = await getLicense(); + return _getEntitlements(license); +} diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index f82bb2282..998563408 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -4,7 +4,8 @@ import * as Sentry from "@sentry/node"; import { getTokenFromConfig } from "@sourcebot/shared"; import { createLogger } from "@sourcebot/shared"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; -import { env, hasEntitlement } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; import micromatch from "micromatch"; import pLimit from "p-limit"; import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; @@ -124,7 +125,7 @@ const getOctokitWithGithubApp = async ( url: string | undefined, context: string ): Promise => { - if (!hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) { + if (!await hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) { return octokit; } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 0999f7771..af3fd489c 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,8 +1,9 @@ import "./instrument.js"; import * as Sentry from "@sentry/node"; -import { PrismaClient } from "@sourcebot/db"; -import { createLogger, env, getConfigSettings, getDBConnectionString, hasEntitlement } from "@sourcebot/shared"; +import { createLogger, env, getConfigSettings } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; +import { prisma } from "./prisma.js"; import 'express-async-errors'; import { existsSync } from 'fs'; import { mkdir } from 'fs/promises'; @@ -31,13 +32,6 @@ if (!existsSync(indexPath)) { await mkdir(indexPath, { recursive: true }); } -const prisma = new PrismaClient({ - datasources: { - db: { - url: getDBConnectionString(), - }, - }, -}); try { await redis.ping(); @@ -51,7 +45,7 @@ const promClient = new PromClient(); const settings = await getConfigSettings(env.CONFIG_PATH); -if (hasEntitlement('github-app')) { +if (await hasEntitlement('github-app')) { await GithubAppManager.getInstance().init(prisma); } @@ -66,11 +60,11 @@ connectionManager.startScheduler(); await repoIndexManager.startScheduler(); auditLogPruner.startScheduler(); -if (env.PERMISSION_SYNC_ENABLED === 'true' && !hasEntitlement('permission-syncing')) { +if (env.PERMISSION_SYNC_ENABLED === 'true' && !await hasEntitlement('permission-syncing')) { logger.error('Permission syncing is not supported in current plan. Please contact team@sourcebot.dev for assistance.'); process.exit(1); } -else if (env.PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')) { +else if (env.PERMISSION_SYNC_ENABLED === 'true' && await hasEntitlement('permission-syncing')) { if (env.PERMISSION_SYNC_REPO_DRIVEN_ENABLED === 'true') { repoPermissionSyncer.startScheduler(); } diff --git a/packages/backend/src/prisma.ts b/packages/backend/src/prisma.ts new file mode 100644 index 000000000..325d50db7 --- /dev/null +++ b/packages/backend/src/prisma.ts @@ -0,0 +1,10 @@ +import { PrismaClient } from "@sourcebot/db"; +import { getDBConnectionString } from "@sourcebot/shared"; + +export const prisma = new PrismaClient({ + datasources: { + db: { + url: getDBConnectionString(), + }, + }, +}); diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index a69508515..d99727ea3 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -5,7 +5,7 @@ import { getTokenFromConfig } from "@sourcebot/shared"; import * as Sentry from "@sentry/node"; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { GithubAppManager } from "./ee/githubAppManager.js"; -import { hasEntitlement } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; import { StatusCodes } from "http-status-codes"; import { isOctokitRequestError } from "./github.js"; @@ -116,7 +116,7 @@ export const fetchWithRetry = async ( // may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referencing. export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logger?: Logger): Promise => { // If we have github apps configured we assume that we must use them for github service auth - if (repo.external_codeHostType === 'github' && hasEntitlement('github-app') && GithubAppManager.getInstance().appsConfigured()) { + if (repo.external_codeHostType === 'github' && await hasEntitlement('github-app') && GithubAppManager.getInstance().appsConfigured()) { logger?.debug(`Using GitHub App for service auth for repo ${repo.displayName} hosted at ${repo.external_codeHostUrl}`); const owner = repo.displayName?.split('/')[0]; diff --git a/packages/backend/vitest.config.ts b/packages/backend/vitest.config.ts index 5b2ab0d5d..6bdbe819b 100644 --- a/packages/backend/vitest.config.ts +++ b/packages/backend/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ test: { @@ -6,6 +7,9 @@ export default defineConfig({ watch: false, env: { DATA_CACHE_DIR: 'test-data' - } + }, + alias: { + './prisma.js': path.resolve(__dirname, 'src/__mocks__/prisma.ts'), + }, } }); \ No newline at end of file diff --git a/packages/db/prisma/migrations/20260417011834_add_license_table/migration.sql b/packages/db/prisma/migrations/20260417011834_add_license_table/migration.sql new file mode 100644 index 000000000..ff858260e --- /dev/null +++ b/packages/db/prisma/migrations/20260417011834_add_license_table/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "License" ( + "id" TEXT NOT NULL, + "orgId" INTEGER NOT NULL, + "activationCode" TEXT NOT NULL, + "entitlements" TEXT[], + "seats" INTEGER, + "status" TEXT, + "lastSyncAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "License_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "License_orgId_key" ON "License"("orgId"); + +-- AddForeignKey +ALTER TABLE "License" ADD CONSTRAINT "License_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260417224042_remove_guest_org_role/migration.sql b/packages/db/prisma/migrations/20260417224042_remove_guest_org_role/migration.sql new file mode 100644 index 000000000..036828359 --- /dev/null +++ b/packages/db/prisma/migrations/20260417224042_remove_guest_org_role/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - The values [GUEST] on the enum `OrgRole` will be removed. If these variants are still used in the database, this will fail. + +*/ + +-- Remove the guest user and its membership (only holder of GUEST role) +DELETE FROM "UserToOrg" WHERE "role" = 'GUEST'; +DELETE FROM "User" WHERE id = '1'; + +-- AlterEnum +BEGIN; +CREATE TYPE "OrgRole_new" AS ENUM ('OWNER', 'MEMBER'); +ALTER TABLE "UserToOrg" ALTER COLUMN "role" DROP DEFAULT; +ALTER TABLE "UserToOrg" ALTER COLUMN "role" TYPE "OrgRole_new" USING ("role"::text::"OrgRole_new"); +ALTER TYPE "OrgRole" RENAME TO "OrgRole_old"; +ALTER TYPE "OrgRole_new" RENAME TO "OrgRole"; +DROP TYPE "OrgRole_old"; +ALTER TABLE "UserToOrg" ALTER COLUMN "role" SET DEFAULT 'MEMBER'; +COMMIT; diff --git a/packages/db/prisma/migrations/20260418213423_add_billing_details_to_license/migration.sql b/packages/db/prisma/migrations/20260418213423_add_billing_details_to_license/migration.sql new file mode 100644 index 000000000..b73f7cf83 --- /dev/null +++ b/packages/db/prisma/migrations/20260418213423_add_billing_details_to_license/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "License" ADD COLUMN "currency" TEXT, +ADD COLUMN "interval" TEXT, +ADD COLUMN "intervalCount" INTEGER, +ADD COLUMN "nextRenewalAmount" INTEGER, +ADD COLUMN "nextRenewalAt" TIMESTAMP(3), +ADD COLUMN "planName" TEXT, +ADD COLUMN "unitAmount" INTEGER; diff --git a/packages/db/prisma/migrations/20260421184633_add_cancel_at_to_license/migration.sql b/packages/db/prisma/migrations/20260421184633_add_cancel_at_to_license/migration.sql new file mode 100644 index 000000000..0284a80c0 --- /dev/null +++ b/packages/db/prisma/migrations/20260421184633_add_cancel_at_to_license/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "License" ADD COLUMN "cancelAt" TIMESTAMP(3); diff --git a/packages/db/prisma/migrations/20260422203048_add_trial_fields/migration.sql b/packages/db/prisma/migrations/20260422203048_add_trial_fields/migration.sql new file mode 100644 index 000000000..95ba32777 --- /dev/null +++ b/packages/db/prisma/migrations/20260422203048_add_trial_fields/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "License" ADD COLUMN "trialEnd" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "trialUsedAt" TIMESTAMP(3); diff --git a/packages/db/prisma/migrations/20260422204809_add_has_payment_method/migration.sql b/packages/db/prisma/migrations/20260422204809_add_has_payment_method/migration.sql new file mode 100644 index 000000000..68ae21946 --- /dev/null +++ b/packages/db/prisma/migrations/20260422204809_add_has_payment_method/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "License" ADD COLUMN "hasPaymentMethod" BOOLEAN; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 3a96eea6e..ad9f39e3d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -291,12 +291,40 @@ model Org { searchContexts SearchContext[] chats Chat[] + + license License? + + /// Set the first time this instance is seen to be on a trial subscription. + /// Never cleared. Used to gate the "Start trial" CTA in the UI. + trialUsedAt DateTime? +} + +model License { + id String @id @default(cuid()) + orgId Int @unique + org Org @relation(fields: [orgId], references: [id]) + activationCode String + entitlements String[] + seats Int? + status String? /// See LicenseStatus in packages/shared/src/types.ts + planName String? + unitAmount Int? + currency String? + interval String? + intervalCount Int? + nextRenewalAt DateTime? + nextRenewalAmount Int? + cancelAt DateTime? + trialEnd DateTime? + hasPaymentMethod Boolean? + lastSyncAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum OrgRole { OWNER MEMBER - GUEST } model UserToOrg { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 6dd5836fc..38a30bf59 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -12,8 +12,6 @@ export const API_KEY_PREFIX = 'sbk_'; export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_'; export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_'; -export const SOURCEBOT_UNLIMITED_SEATS = -1; - /** * Default settings. */ diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index ae2aa423c..fbb4be79b 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -27,6 +27,21 @@ export function encrypt(text: string): { iv: string; encryptedData: string } { return { iv: iv.toString('hex'), encryptedData: encrypted }; } +export function decrypt(iv: string, encryptedText: string): string { + const encryptionKey = Buffer.from(env.SOURCEBOT_ENCRYPTION_KEY, 'ascii'); + + const ivBuffer = Buffer.from(iv, 'hex'); + const encryptedBuffer = Buffer.from(encryptedText, 'hex'); + + const decipher = crypto.createDecipheriv(algorithm, encryptionKey, ivBuffer); + + let decrypted = decipher.update(encryptedBuffer, undefined, 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} + + export function hashSecret(text: string): string { return crypto.createHmac('sha256', env.SOURCEBOT_ENCRYPTION_KEY).update(text).digest('hex'); } @@ -61,20 +76,6 @@ export function generateOAuthRefreshToken(): { token: string; hash: string } { }; } -export function decrypt(iv: string, encryptedText: string): string { - const encryptionKey = Buffer.from(env.SOURCEBOT_ENCRYPTION_KEY, 'ascii'); - - const ivBuffer = Buffer.from(iv, 'hex'); - const encryptedBuffer = Buffer.from(encryptedText, 'hex'); - - const decipher = crypto.createDecipheriv(algorithm, encryptionKey, ivBuffer); - - let decrypted = decipher.update(encryptedBuffer, undefined, 'utf8'); - decrypted += decipher.final('utf8'); - - return decrypted; -} - export function verifySignature(data: string, signature: string, publicKeyPath: string): boolean { try { let publicKey = publicKeyCache.get(publicKeyPath); @@ -226,3 +227,13 @@ export function decryptOAuthToken(encryptedText: string | null | undefined): str return encryptedText; } } + +export function encryptActivationCode(code: string): string { + const { iv, encryptedData } = encrypt(code); + return Buffer.from(JSON.stringify({ iv, encryptedData })).toString('base64'); +} + +export function decryptActivationCode(encrypted: string): string { + const { iv, encryptedData } = JSON.parse(Buffer.from(encrypted, 'base64').toString('utf8')); + return decrypt(iv, encryptedData); +} \ No newline at end of file diff --git a/packages/shared/src/entitlements.test.ts b/packages/shared/src/entitlements.test.ts new file mode 100644 index 000000000..b6ff79239 --- /dev/null +++ b/packages/shared/src/entitlements.test.ts @@ -0,0 +1,238 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { License } from '@sourcebot/db'; + +const mocks = vi.hoisted(() => ({ + env: { + SOURCEBOT_PUBLIC_KEY_PATH: '/tmp/test-key', + SOURCEBOT_EE_LICENSE_KEY: undefined as string | undefined, + } as Record, + verifySignature: vi.fn(() => true), +})); + +vi.mock('./env.server.js', () => ({ + env: mocks.env, +})); + +vi.mock('./crypto.js', () => ({ + verifySignature: mocks.verifySignature, +})); + +vi.mock('./logger.js', () => ({ + createLogger: () => ({ + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +import { + isAnonymousAccessAvailable, + getEntitlements, + hasEntitlement, +} from './entitlements.js'; + +const encodeOfflineKey = (payload: object): string => { + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `sourcebot_ee_${encoded}`; +}; + +const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString(); +const pastDate = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(); + +const validOfflineKey = (overrides: { seats?: number; expiryDate?: string } = {}) => + encodeOfflineKey({ + id: 'test-customer', + expiryDate: overrides.expiryDate ?? futureDate, + ...(overrides.seats !== undefined ? { seats: overrides.seats } : {}), + sig: 'fake-sig', + }); + +const makeLicense = (overrides: Partial = {}): License => ({ + id: 'lic_1', + orgId: 1, + activationCode: 'code', + entitlements: [], + seats: null, + status: null, + planName: null, + unitAmount: null, + currency: null, + interval: null, + intervalCount: null, + nextRenewalAt: null, + nextRenewalAmount: null, + cancelAt: null, + trialEnd: null, + hasPaymentMethod: null, + lastSyncAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); + +beforeEach(() => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = undefined; + mocks.verifySignature.mockReturnValue(true); +}); + +describe('isAnonymousAccessAvailable', () => { + describe('without any license', () => { + test('returns true when license is null', () => { + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('returns true when license has no status', () => { + expect(isAnonymousAccessAvailable(makeLicense())).toBe(true); + }); + + test('returns true when license status is canceled', () => { + expect(isAnonymousAccessAvailable(makeLicense({ status: 'canceled' }))).toBe(true); + }); + }); + + describe('with an active online license', () => { + test.each(['active', 'trialing', 'past_due'] as const)( + 'returns false when status is %s', + (status) => { + expect(isAnonymousAccessAvailable(makeLicense({ status }))).toBe(false); + } + ); + }); + + describe('with an offline license key', () => { + test('returns false when offline key has a seat count', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 }); + expect(isAnonymousAccessAvailable(null)).toBe(false); + }); + + test('returns true when offline key has no seat count (unlimited)', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey(); + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('unlimited offline key beats an active online license', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey(); + expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(true); + }); + + test('falls through to online license check when offline key is expired', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100, expiryDate: pastDate }); + expect(isAnonymousAccessAvailable(null)).toBe(true); + expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(false); + }); + + test('falls through when offline key is malformed', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'sourcebot_ee_not-valid-base64-or-json'; + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('falls through when offline key has wrong prefix', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'bogus_prefix_xyz'; + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('falls through when offline key signature is invalid', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 }); + mocks.verifySignature.mockReturnValue(false); + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + }); +}); + +describe('getEntitlements', () => { + test('returns empty array when no license and no offline key', () => { + expect(getEntitlements(null)).toEqual([]); + }); + + test('returns license.entitlements when license is active', () => { + const license = makeLicense({ status: 'active', entitlements: ['sso', 'audit'] }); + expect(getEntitlements(license)).toEqual(['sso', 'audit']); + }); + + test('returns empty when license has no status', () => { + expect(getEntitlements(makeLicense({ entitlements: ['sso'] }))).toEqual([]); + }); + + test('returns all entitlements when offline key is valid', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 }); + const result = getEntitlements(null); + expect(result).toContain('sso'); + expect(result).toContain('audit'); + expect(result).toContain('search-contexts'); + }); + + test('falls through when offline key is expired', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50, expiryDate: pastDate }); + expect(getEntitlements(null)).toEqual([]); + expect( + getEntitlements(makeLicense({ status: 'active', entitlements: ['sso'] })) + ).toEqual(['sso']); + }); + + test('falls through when offline key is malformed', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'sourcebot_ee_not-a-valid-payload'; + expect(getEntitlements(null)).toEqual([]); + }); + + describe('online license staleness', () => { + const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; + + test('returns entitlements when lastSyncAt is recent', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + lastSyncAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // 1 day ago + }); + expect(getEntitlements(license)).toEqual(['sso']); + }); + + test('returns empty when lastSyncAt is past the stale threshold', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + lastSyncAt: new Date(Date.now() - STALE_THRESHOLD_MS - 60 * 1000), // 7d + 1min + }); + expect(getEntitlements(license)).toEqual([]); + }); + + test('returns empty when lastSyncAt is null', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + lastSyncAt: null, + }); + expect(getEntitlements(license)).toEqual([]); + }); + + test('returns entitlements at the threshold boundary', () => { + // Exactly at the threshold should still be treated as valid (<=). + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + lastSyncAt: new Date(Date.now() - STALE_THRESHOLD_MS + 1000), + }); + expect(getEntitlements(license)).toEqual(['sso']); + }); + }); +}); + +describe('hasEntitlement', () => { + test('returns true when entitlement is present in license', () => { + expect( + hasEntitlement('sso', makeLicense({ status: 'active', entitlements: ['sso'] })) + ).toBe(true); + }); + + test('returns false when entitlement is absent from license', () => { + expect( + hasEntitlement('audit', makeLicense({ status: 'active', entitlements: ['sso'] })) + ).toBe(false); + }); + + test('returns true for any entitlement when offline key is valid', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 }); + expect(hasEntitlement('sso', null)).toBe(true); + expect(hasEntitlement('audit', null)).toBe(true); + }); +}); diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index de841a0dd..f20270513 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -2,36 +2,32 @@ import { base64Decode } from "./utils.js"; import { z } from "zod"; import { createLogger } from "./logger.js"; import { env } from "./env.server.js"; -import { SOURCEBOT_SUPPORT_EMAIL, SOURCEBOT_UNLIMITED_SEATS } from "./constants.js"; import { verifySignature } from "./crypto.js"; +import { License } from "@sourcebot/db"; +import { LicenseStatus } from "./types.js"; const logger = createLogger('entitlements'); -const eeLicenseKeyPrefix = "sourcebot_ee_"; - -const eeLicenseKeyPayloadSchema = z.object({ +const offlineLicensePrefix = "sourcebot_ee_"; +const offlineLicensePayloadSchema = z.object({ id: z.string(), - seats: z.number(), + seats: z.number().optional(), // ISO 8601 date string expiryDate: z.string().datetime(), sig: z.string(), }); -type LicenseKeyPayload = z.infer; +type getValidOfflineLicense = z.infer; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const planLabels = { - oss: "OSS", - "self-hosted:enterprise": "Enterprise (Self-Hosted)", - "self-hosted:enterprise-unlimited": "Enterprise (Self-Hosted) Unlimited", -} as const; -export type Plan = keyof typeof planLabels; +const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [ + 'active', + 'trialing', + 'past_due', +]; // eslint-disable-next-line @typescript-eslint/no-unused-vars -const entitlements = [ +const ALL_ENTITLEMENTS = [ "search-contexts", - "anonymous-access", - "multi-tenancy", "sso", "code-nav", "audit", @@ -42,100 +38,143 @@ const entitlements = [ "org-management", "oauth", ] as const; -export type Entitlement = (typeof entitlements)[number]; - -const entitlementsByPlan: Record = { - oss: [ - "anonymous-access", - ], - "self-hosted:enterprise": [ - "search-contexts", - "sso", - "code-nav", - "audit", - "analytics", - "permission-syncing", - "github-app", - "chat-sharing", - "org-management", - "oauth", - ], - "self-hosted:enterprise-unlimited": [ - "anonymous-access", - "search-contexts", - "sso", - "code-nav", - "audit", - "analytics", - "permission-syncing", - "github-app", - "chat-sharing", - "org-management", - "oauth", - ], -} as const; - - -const decodeLicenseKeyPayload = (payload: string): LicenseKeyPayload => { +export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; + +const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense | null => { try { const decodedPayload = base64Decode(payload); const payloadJson = JSON.parse(decodedPayload); - const licenseData = eeLicenseKeyPayloadSchema.parse(payloadJson); - + const licenseData = offlineLicensePayloadSchema.parse(payloadJson); + const dataToVerify = JSON.stringify({ expiryDate: licenseData.expiryDate, id: licenseData.id, seats: licenseData.seats }); - + const isSignatureValid = verifySignature(dataToVerify, licenseData.sig, env.SOURCEBOT_PUBLIC_KEY_PATH); if (!isSignatureValid) { logger.error('License key signature verification failed'); - process.exit(1); + return null; } - + return licenseData; } catch (error) { logger.error(`Failed to decode license key payload: ${error}`); - process.exit(1); + return null; } } -export const getLicenseKey = (): LicenseKeyPayload | null => { +const getDecodedOfflineLicense = (): getValidOfflineLicense | null => { const licenseKey = env.SOURCEBOT_EE_LICENSE_KEY; - if (licenseKey && licenseKey.startsWith(eeLicenseKeyPrefix)) { - const payload = licenseKey.substring(eeLicenseKeyPrefix.length); - return decodeLicenseKeyPayload(payload); + if (!licenseKey || !licenseKey.startsWith(offlineLicensePrefix)) { + return null; + } + + return decodeOfflineLicenseKeyPayload(licenseKey.substring(offlineLicensePrefix.length)); +} + +const getValidOfflineLicense = (): getValidOfflineLicense | null => { + const payload = getDecodedOfflineLicense(); + if (!payload) { + return null; + } + + const expiryDate = new Date(payload.expiryDate); + if (expiryDate.getTime() < new Date().getTime()) { + return null; + } + + return payload; +} + +// If the license hasn't successfully synced with Lighthouse for this long, +// the locally-cached state is no longer trusted. This guards against an +// operator blocking egress to prevent the license row from hearing about +// a canceled or past-due subscription. 7 days absorbs week-long transient +// outages (weekends, firewall rollouts) without punishing legitimate +// customers. +export const STALE_ONLINE_LICENSE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; + +// Surface a UI warning (banner + "refreshed" timestamp color) when the +// license hasn't synced for this long. Must be < the enforcement threshold +// so the warning has a chance to fire before entitlements are stripped. +export const STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS = 48 * 60 * 60 * 1000; + +const getValidOnlineLicense = (_license: License | null): License | null => { + if ( + _license && + _license.status && + ACTIVE_ONLINE_LICENSE_STATUSES.includes(_license.status as LicenseStatus) && + _license.lastSyncAt && + (Date.now() - _license.lastSyncAt.getTime()) <= STALE_ONLINE_LICENSE_THRESHOLD_MS + ) { + return _license; } + return null; } -export const getPlan = (): Plan => { - const licenseKey = getLicenseKey(); - if (licenseKey) { - const expiryDate = new Date(licenseKey.expiryDate); - if (expiryDate.getTime() < new Date().getTime()) { - logger.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`); - process.exit(1); - } +export const isAnonymousAccessAvailable = (_license: License | null): boolean => { + const offlineKey = getValidOfflineLicense(); + if (offlineKey) { + return offlineKey.seats === undefined; + } - return licenseKey.seats === SOURCEBOT_UNLIMITED_SEATS ? "self-hosted:enterprise-unlimited" : "self-hosted:enterprise"; - } else { - return "oss"; + const onlineLicense = getValidOnlineLicense(_license); + if (onlineLicense) { + return false; } + return true; } -export const getSeats = (): number => { -const licenseKey = getLicenseKey(); - return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS; +export const getEntitlements = (_license: License | null): Entitlement[] => { + const offlineLicense = getValidOfflineLicense(); + if (offlineLicense) { + return ALL_ENTITLEMENTS as unknown as Entitlement[]; + } + + const onlineLicense = getValidOnlineLicense(_license); + if (onlineLicense) { + return onlineLicense.entitlements as unknown as Entitlement[]; + } + else { + return []; + } } -export const hasEntitlement = (entitlement: Entitlement) => { - const entitlements = getEntitlements(); +export const hasEntitlement = (entitlement: Entitlement, _license: License | null) => { + const entitlements = getEntitlements(_license); return entitlements.includes(entitlement); } -export const getEntitlements = (): Entitlement[] => { - const plan = getPlan(); - return entitlementsByPlan[plan]; +export type OfflineLicenseMetadata = { + id: string; + seats?: number; + expiryDate: string; +} + +// Returns the metadata of the offline license if one is configured, even +// if it has expired. Callers that only care about active entitlements +// should use `getEntitlements` / `getValidOfflineLicense` instead. +export const getOfflineLicenseMetadata = (): OfflineLicenseMetadata | null => { + const license = getDecodedOfflineLicense(); + if (!license) { + return null; + } + + return { + id: license.id, + seats: license.seats, + expiryDate: license.expiryDate, + }; } + +export const getSeatCap = (): number | undefined => { + const offlineLicense = getValidOfflineLicense(); + if (offlineLicense?.seats && offlineLicense.seats > 0) { + return offlineLicense.seats; + } + + return undefined; +} \ No newline at end of file diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index 22bf2242e..cc51d768c 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -269,6 +269,7 @@ const options = { SOURCEBOT_ENCRYPTION_KEY: z.string(), SOURCEBOT_INSTALL_ID: z.string().default("unknown"), + SOURCEBOT_LIGHTHOUSE_URL: z.string().url(), FALLBACK_GITHUB_CLOUD_TOKEN: z.string().optional(), FALLBACK_GITLAB_CLOUD_TOKEN: z.string().optional(), diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index a1eb34204..8be1b35c2 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -1,18 +1,24 @@ +// types prefixed with _ are intended to be wrapped +// by the consumer. See web/entitlements.ts and +// backend/entitlements.ts export { - hasEntitlement, - getLicenseKey, - getPlan, - getSeats, - getEntitlements, + hasEntitlement as _hasEntitlement, + getEntitlements as _getEntitlements, + isAnonymousAccessAvailable as _isAnonymousAccessAvailable, + getSeatCap, + getOfflineLicenseMetadata, + STALE_ONLINE_LICENSE_THRESHOLD_MS, + STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS, } from "./entitlements.js"; export type { - Plan, Entitlement, + OfflineLicenseMetadata, } from "./entitlements.js"; export type { RepoMetadata, RepoIndexingJobMetadata, IdentityProviderType, + LicenseStatus, } from "./types.js"; export { repoMetadataSchema, @@ -49,6 +55,8 @@ export { verifySignature, encryptOAuthToken, decryptOAuthToken, + encryptActivationCode, + decryptActivationCode, } from "./crypto.js"; export { getDBConnectionString, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index b0291a57b..3045986b2 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -64,4 +64,15 @@ export const repoIndexingJobMetadataSchema = z.object({ export type RepoIndexingJobMetadata = z.infer; -export type IdentityProviderType = IdentityProviderConfig['provider']; \ No newline at end of file +export type IdentityProviderType = IdentityProviderConfig['provider']; + +// @see: https://docs.stripe.com/api/subscriptions/object#subscription_object-status +export type LicenseStatus = + 'active' | + 'trialing' | + 'past_due' | + 'unpaid' | + 'canceled' | + 'incomplete' | + 'incomplete_expired' | + 'paused'; diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index 148b3a18b..74728183f 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ NODE_ENV: 'test', CONFIG_PATH: '/tmp/test-config.json', SOURCEBOT_ENCRYPTION_KEY: 'test-encryption-key-32-characters!', + SOURCEBOT_LIGHTHOUSE_URL: 'http://localhost:3003', } } }); diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index a2d11360e..18c70c33e 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -20,7 +20,8 @@ export const MOCK_ORG: Org = { metadata: null, memberApprovalRequired: false, inviteLinkEnabled: false, - inviteLinkId: null + inviteLinkId: null, + trialUsedAt: null, } export const MOCK_API_KEY: ApiKey = { diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 939ba442c..1a3396c26 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,11 +1,10 @@ 'use server'; -import { getAuditService } from "@/ee/features/audit/factory"; +import { createAudit } from "@/ee/features/audit/audit"; import { env, getSMTPConnectionURL } from "@sourcebot/shared"; -import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, orgNotFound, ServiceError } from "@/lib/serviceError"; -import { getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils"; +import { notAuthenticated, notFound, ServiceError } from "@/lib/serviceError"; +import { getOrgMetadata, isHttpError } from "@/lib/utils"; import { __unsafePrisma } from "@/prisma"; import { render } from "@react-email/components"; import { generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; @@ -14,13 +13,11 @@ import { createLogger } from "@sourcebot/shared"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; -import { getPlan, hasEntitlement } from "@sourcebot/shared"; +import { isAnonymousAccessAvailable } from "@/lib/entitlements"; import { StatusCodes } from "http-status-codes"; import { cookies } from "next/headers"; import { createTransport } from "nodemailer"; import { Octokit } from "octokit"; -import InviteUserEmail from "./emails/inviteUserEmail"; -import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { RepositoryQuery } from "./lib/types"; @@ -30,7 +27,6 @@ import { getBrowsePath } from "./app/(app)/browse/hooks/utils"; import { sew } from "@/middleware/sew"; const logger = createLogger('web-actions'); -const auditService = getAuditService(); ////// Actions /////// export const completeOnboarding = async (): Promise<{ success: boolean } | ServiceError> => sew(() => @@ -66,7 +62,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv }); if (existingApiKey) { - await auditService.createAudit({ + await createAudit({ action: "api_key.creation_failed", actor: { id: user.id, @@ -99,7 +95,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv } }); - await auditService.createAudit({ + await createAudit({ action: "api_key.created", actor: { id: user.id, @@ -127,7 +123,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } | }); if (!apiKey) { - await auditService.createAudit({ + await createAudit({ action: "api_key.deletion_failed", actor: { id: user.id, @@ -156,7 +152,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } | }, }); - await auditService.createAudit({ + await createAudit({ action: "api_key.deleted", actor: { id: user.id, @@ -519,304 +515,6 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin } })); -export const getCurrentUserRole = async (): Promise => sew(() => - withOptionalAuth(async ({ role }) => { - return role; - })); - -export const createInvites = async (emails: string[]): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ org, user, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const failAuditCallback = async (error: string) => { - await auditService.createAudit({ - action: "user.invite_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - message: error, - emails: emails.join(", ") - } - }); - } - - const hasAvailability = await orgHasAvailability(); - if (!hasAvailability) { - await auditService.createAudit({ - action: "user.invite_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - message: "Organization has reached maximum number of seats", - emails: emails.join(", ") - } - }); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, - message: "The organization has reached the maximum number of seats. Unable to create a new invite", - } satisfies ServiceError; - } - - // Check for existing invites - const existingInvites = await prisma.invite.findMany({ - where: { - recipientEmail: { - in: emails - }, - orgId: org.id, - } - }); - - if (existingInvites.length > 0) { - await failAuditCallback("A pending invite already exists for one or more of the provided emails"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_INVITE, - message: `A pending invite already exists for one or more of the provided emails.`, - } satisfies ServiceError; - } - - // Check for members that are already in the org - const existingMembers = await prisma.userToOrg.findMany({ - where: { - user: { - email: { - in: emails, - } - }, - orgId: org.id, - }, - }); - - if (existingMembers.length > 0) { - await failAuditCallback("One or more of the provided emails are already members of this org"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_INVITE, - message: `One or more of the provided emails are already members of this org.`, - } satisfies ServiceError; - } - - await prisma.invite.createMany({ - data: emails.map((email) => ({ - recipientEmail: email, - hostUserId: user.id, - orgId: org.id, - })), - skipDuplicates: true, - }); - - // Send invites to recipients - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - await Promise.all(emails.map(async (email) => { - const invite = await prisma.invite.findUnique({ - where: { - recipientEmail_orgId: { - recipientEmail: email, - orgId: org.id, - }, - }, - include: { - org: true, - } - }); - - if (!invite) { - return; - } - - const recipient = await prisma.user.findUnique({ - where: { - email, - }, - }); - const inviteLink = `${env.AUTH_URL}/redeem?invite_id=${invite.id}`; - const transport = createTransport(smtpConnectionUrl); - const html = await render(InviteUserEmail({ - baseUrl: env.AUTH_URL, - host: { - name: user.name ?? undefined, - email: user.email!, - avatarUrl: user.image ?? undefined, - }, - recipient: { - name: recipient?.name ?? undefined, - }, - orgName: invite.org.name, - orgImageUrl: invite.org.imageUrl ?? undefined, - inviteLink, - })); - - const result = await transport.sendMail({ - to: email, - from: env.EMAIL_FROM_ADDRESS, - subject: `Join ${invite.org.name} on Sourcebot`, - html, - text: `Join ${invite.org.name} on Sourcebot by clicking here: ${inviteLink}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send invite email to ${email}: ${failed}`); - } - })); - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`); - } - - await auditService.createAudit({ - action: "user.invites_created", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - emails: emails.join(", ") - } - }); - return { - success: true, - } - }) - )); - -export const cancelInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const invite = await prisma.invite.findUnique({ - where: { - id: inviteId, - orgId: org.id, - }, - }); - - if (!invite) { - return notFound(); - } - - await prisma.invite.delete({ - where: { - id: inviteId, - }, - }); - - return { - success: true, - } - }) - )); - -export const getMe = async () => sew(() => - withAuth(async ({ user, prisma }) => { - const userWithOrgs = await prisma.user.findUnique({ - where: { - id: user.id, - }, - include: { - orgs: { - include: { - org: true, - } - }, - } - }); - - if (!userWithOrgs) { - return notFound(); - } - - return { - id: userWithOrgs.id, - email: userWithOrgs.email, - name: userWithOrgs.name, - image: userWithOrgs.image, - memberships: userWithOrgs.orgs.map((org) => ({ - id: org.orgId, - role: org.role, - name: org.org.name, - })) - } - })); - -export const getOrgMembers = async () => sew(() => - withAuth(async ({ org, prisma }) => { - const members = await prisma.userToOrg.findMany({ - where: { - orgId: org.id, - role: { - not: OrgRole.GUEST, - } - }, - include: { - user: true, - }, - }); - - return members.map((member) => ({ - id: member.userId, - email: member.user.email!, - name: member.user.name ?? undefined, - avatarUrl: member.user.image ?? undefined, - role: member.role, - joinedAt: member.joinedAt, - })); - })); - -export const getOrgInvites = async () => sew(() => - withAuth(async ({ org, prisma }) => { - const invites = await prisma.invite.findMany({ - where: { - orgId: org.id, - }, - }); - - return invites.map((invite) => ({ - id: invite.id, - email: invite.recipientEmail, - createdAt: invite.createdAt, - })); - })); - -export const getOrgAccountRequests = async () => sew(() => - withAuth(async ({ org, prisma }) => { - const requests = await prisma.accountRequest.findMany({ - where: { - orgId: org.id, - }, - include: { - requestedBy: true, - }, - }); - - return requests.map((request) => ({ - id: request.id, - email: request.requestedBy.email!, - createdAt: request.createdAt, - name: request.requestedBy.name ?? undefined, - image: request.requestedBy.image ?? undefined, - })); - })); - export const createAccountRequest = async () => sew(async () => { const authResult = await getAuthenticatedUser(); if (!authResult) { @@ -920,20 +618,6 @@ export const createAccountRequest = async () => sew(async () => { } }); -export const getMemberApprovalRequired = async (): Promise => sew(async () => { - const org = await __unsafePrisma.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - }); - - if (!org) { - return orgNotFound(); - } - - return org.memberApprovalRequired; -}); - export const setMemberApprovalRequired = async (required: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => withAuth(async ({ org, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { @@ -964,121 +648,6 @@ export const setInviteLinkEnabled = async (enabled: boolean): Promise<{ success: ) ); -export const approveAccountRequest = async (requestId: string) => sew(async () => - withAuth(async ({ org, user, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const failAuditCallback = async (error: string) => { - await auditService.createAudit({ - action: "user.join_request_approve_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: requestId, - type: "account_join_request" - }, - orgId: org.id, - metadata: { - message: error, - } - }); - } - - const request = await prisma.accountRequest.findUnique({ - where: { - id: requestId, - }, - include: { - requestedBy: true, - }, - }); - - if (!request || request.orgId !== org.id) { - await failAuditCallback("Request not found"); - return notFound(); - } - - const addUserToOrgRes = await addUserToOrganization(request.requestedById, org.id); - if (isServiceError(addUserToOrgRes)) { - await failAuditCallback(addUserToOrgRes.message); - return addUserToOrgRes; - } - - // Send approval email to the user - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - const html = await render(JoinRequestApprovedEmail({ - baseUrl: env.AUTH_URL, - user: { - name: request.requestedBy.name ?? undefined, - email: request.requestedBy.email!, - avatarUrl: request.requestedBy.image ?? undefined, - }, - orgName: org.name, - })); - - const transport = createTransport(smtpConnectionUrl); - const result = await transport.sendMail({ - to: request.requestedBy.email!, - from: env.EMAIL_FROM_ADDRESS, - subject: `Your request to join ${org.name} has been approved`, - html, - text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`); - } - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`); - } - - await auditService.createAudit({ - action: "user.join_request_approved", - actor: { - id: user.id, - type: "user" - }, - orgId: org.id, - target: { - id: requestId, - type: "account_join_request" - } - }); - return { - success: true, - } - }) - )); - -export const rejectAccountRequest = async (requestId: string) => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const request = await prisma.accountRequest.findUnique({ - where: { - id: requestId, - }, - }); - - if (!request || request.orgId !== org.id) { - return notFound(); - } - - await prisma.accountRequest.delete({ - where: { - id: requestId, - }, - }); - - return { - success: true, - } - }) - )); - - export const getSearchContexts = async () => sew(() => withOptionalAuth(async ({ org, prisma }) => { const searchContexts = await prisma.searchContext.findMany({ @@ -1167,42 +736,12 @@ export const getRepoImage = async (repoId: number): Promise => sew(async () => { - const org = await __unsafePrisma.org.findUnique({ - where: { id: SINGLE_TENANT_ORG_ID }, - }); - if (!org) { - return { - statusCode: StatusCodes.NOT_FOUND, - errorCode: ErrorCode.NOT_FOUND, - message: "Organization not found", - } satisfies ServiceError; - } - - // If no metadata is set we don't try to parse it since it'll result in a parse error - if (org.metadata === null) { - return false; - } - - const orgMetadata = getOrgMetadata(org); - if (!orgMetadata) { - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.INVALID_ORG_METADATA, - message: "Invalid organization metadata", - } satisfies ServiceError; - } - - return !!orgMetadata.anonymousAccessEnabled; -}); - export const setAnonymousAccessStatus = async (enabled: boolean): Promise => sew(async () => { return await withAuth(async ({ org, role, prisma }) => { return await withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); - if (!hasAnonymousAccessEntitlement) { - const plan = getPlan(); - console.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); + const anonymousAccessAvailable = await isAnonymousAccessAvailable(); + if (!anonymousAccessAvailable) { + console.error(`Anonymous access isn't supported in your current plan. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); return { statusCode: StatusCodes.FORBIDDEN, errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx index d222518a7..c215af57c 100644 --- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx @@ -2,7 +2,8 @@ import { cookies } from "next/headers"; import { auth } from "@/auth"; import { HOME_VIEW_COOKIE_NAME } from "@/lib/constants"; import { HomeView } from "@/hooks/useHomeView"; -import { getConnectionStats, getOrgAccountRequests } from "@/actions"; +import { getConnectionStats } from "@/actions"; +import { getOrgAccountRequests } from "@/features/userManagement/actions"; import { isServiceError } from "@/lib/utils"; import { ServiceErrorException } from "@/lib/serviceError"; import { __unsafePrisma } from "@/prisma"; diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/header.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/header.tsx new file mode 100644 index 000000000..ab2d6b3ab --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/header.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { ArrowLeftIcon } from "lucide-react"; +import Link from "next/link"; + +export function SettingsSidebarHeader() { + return ( + + + + + + Back to app + + + + + ); +} diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx index 85e3d0ae2..fc30ad7f4 100644 --- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx @@ -4,13 +4,7 @@ import { ServiceErrorException } from "@/lib/serviceError"; import { getSidebarNavGroups } from "@/app/(app)/settings/layout"; import { SidebarBase } from "../sidebarBase"; import { Nav } from "./nav"; -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar"; -import { ArrowLeftIcon } from "lucide-react"; -import Link from "next/link"; +import { SettingsSidebarHeader } from "./header"; export async function SettingsSidebar() { const session = await auth(); @@ -24,18 +18,7 @@ export async function SettingsSidebar() { - - - - - Back to app - - - - - } + headerContent={} >