From 2703cec52b775aa62e6837e774f126c599b73f01 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Thu, 25 Jun 2026 01:52:02 -0700 Subject: [PATCH 1/4] feat: remove pricing/licensing, make all features free pgconsole is now entirely free with all features unconditionally enabled. Removes the plan/license system end to end. - Delete plan tiers, license verification, Subscription modal, license expiry banner, the Stripe webhook worker, the website pricing page, and the license docs page. - Ungate features: SSO (Google/Keycloak/Okta), audit logging, groups, banner, and branding are always available. - IAM is now opt-in / off by default: with no [[iam]] rules defined, authenticated users get full access; defining the first rule turns enforcement on. Restores the pre-pricing default and eases onboarding. - Scrub all pricing/plan/license references from docs. - Update tests for the new IAM default; drop license/plan test files. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/authentication/google.mdx | 2 - docs/authentication/keycloak.mdx | 2 - docs/authentication/okta.mdx | 2 - docs/authentication/overview.mdx | 2 +- docs/configuration/config.mdx | 13 +- docs/configuration/license.mdx | 29 - docs/docs.json | 1 - docs/features/audit-log.mdx | 2 - docs/features/database-access-control.mdx | 10 +- docs/features/mcp-server.mdx | 2 +- docs/features/white-labeling.mdx | 2 - docs/getting-started/faq.mdx | 2 +- server/auth-routes.ts | 42 +- server/index.ts | 28 +- server/lib/audit.ts | 4 - server/lib/config.ts | 62 +- server/lib/iam.ts | 18 +- server/lib/license.ts | 46 - src/App.tsx | 9 +- src/components/AuthForm.tsx | 12 +- src/components/Header.tsx | 10 +- src/components/LicenseExpiryBanner.tsx | 47 - src/components/SubscriptionModal.tsx | 177 -- src/hooks/useSetting.ts | 11 - src/hooks/useSubscriptionModal.ts | 15 - src/lib/auth-client.ts | 1 - src/lib/plan.ts | 28 - tests/iam.test.ts | 42 +- tests/license.test.ts | 121 -- tests/mcp.test.ts | 22 +- tests/plan.test.ts | 50 - website/src/app/pricing/page.tsx | 115 - website/src/app/sitemap.ts | 1 - .../sections/plan-comparison-table.tsx | 137 -- .../sections/pricing-hero-multi-tier.tsx | 117 - .../sections/pricing-multi-tier.tsx | 73 - .../pricing-single-tier-two-column.tsx | 58 - website/src/components/shared/navbar.tsx | 1 - worker/stripe-webhook/package-lock.json | 1875 ----------------- worker/stripe-webhook/package.json | 14 - worker/stripe-webhook/src/index.js | 360 ---- worker/stripe-webhook/wrangler.toml | 7 - 42 files changed, 57 insertions(+), 3515 deletions(-) delete mode 100644 docs/configuration/license.mdx delete mode 100644 server/lib/license.ts delete mode 100644 src/components/LicenseExpiryBanner.tsx delete mode 100644 src/components/SubscriptionModal.tsx delete mode 100644 src/hooks/useSubscriptionModal.ts delete mode 100644 src/lib/plan.ts delete mode 100644 tests/license.test.ts delete mode 100644 tests/plan.test.ts delete mode 100644 website/src/app/pricing/page.tsx delete mode 100644 website/src/components/sections/plan-comparison-table.tsx delete mode 100644 website/src/components/sections/pricing-hero-multi-tier.tsx delete mode 100644 website/src/components/sections/pricing-multi-tier.tsx delete mode 100644 website/src/components/sections/pricing-single-tier-two-column.tsx delete mode 100644 worker/stripe-webhook/package-lock.json delete mode 100644 worker/stripe-webhook/package.json delete mode 100644 worker/stripe-webhook/src/index.js delete mode 100644 worker/stripe-webhook/wrangler.toml diff --git a/docs/authentication/google.mdx b/docs/authentication/google.mdx index 25d52bb..cd05895 100644 --- a/docs/authentication/google.mdx +++ b/docs/authentication/google.mdx @@ -2,8 +2,6 @@ title: Google --- -This feature requires the **Team** or **Enterprise** plan. - Allow users to sign in with their Google accounts. ## Prerequisites diff --git a/docs/authentication/keycloak.mdx b/docs/authentication/keycloak.mdx index d1c91b2..0ce8430 100644 --- a/docs/authentication/keycloak.mdx +++ b/docs/authentication/keycloak.mdx @@ -2,8 +2,6 @@ title: Keycloak --- -This feature requires the **Enterprise** plan. - Allow users to sign in with Keycloak, an open source identity and access management solution. ## Prerequisites diff --git a/docs/authentication/okta.mdx b/docs/authentication/okta.mdx index 8b17e94..47f4068 100644 --- a/docs/authentication/okta.mdx +++ b/docs/authentication/okta.mdx @@ -2,8 +2,6 @@ title: Okta --- -This feature requires the **Enterprise** plan. - Allow users to sign in with Okta, a cloud identity and access management platform. ## Prerequisites diff --git a/docs/authentication/overview.mdx b/docs/authentication/overview.mdx index aeeb49d..4bc5a9c 100644 --- a/docs/authentication/overview.mdx +++ b/docs/authentication/overview.mdx @@ -9,7 +9,7 @@ pgconsole supports multiple authentication methods to secure access to your data | Method | Description | Best For | |--------|-------------|----------| | Email/Password | Built-in local authentication | Simple setups, small teams | -| SSO ([Google](/authentication/google), [Okta](/authentication/okta), [Keycloak](/authentication/keycloak)) | OAuth providers | Enterprise, existing identity providers | +| SSO ([Google](/authentication/google), [Okta](/authentication/okta), [Keycloak](/authentication/keycloak)) | OAuth providers | Organizations with existing identity providers | ## Configuration diff --git a/docs/configuration/config.mdx b/docs/configuration/config.mdx index 2b2b528..06b25b3 100644 --- a/docs/configuration/config.mdx +++ b/docs/configuration/config.mdx @@ -27,7 +27,6 @@ pgconsole uses a TOML configuration file for all settings. Pass the config file ```toml pgconsole.toml [general] external_url = "https://pgconsole.example.com" -license = "your-license-key" [general.banner] text = "System maintenance scheduled for Sunday 2am UTC" @@ -142,12 +141,10 @@ permissions = ["read"] | Field | Description | Required | |-------|-------------|----------| | `external_url` | Public URL of the application. See [Configure External Access](/configuration/external-access). | Required for SSO | -| `license` | License key. See [Manage License](/configuration/license). | | ```toml pgconsole.toml [general] external_url = "https://pgconsole.example.com" -license = "your-license-key" ``` ### Announcement Banner @@ -169,7 +166,7 @@ color = "#7c3aed" ## Branding -Replace the pgconsole logo with your own. Requires an [Enterprise license](/configuration/license). +Replace the pgconsole logo with your own. | Field | Description | Required | |-------|-------------|----------| @@ -307,7 +304,7 @@ User entries. Repeat for multiple users. Users with a `password` can sign in wit |-------|-------------|----------| | `email` | User email or identifier | Yes | | `password` | Login password (omit for SSO-only users) | | -| `owner` | Grants access to subscription management | | +| `owner` | Marks the user as an owner (shown with an Owner badge) | | ```toml pgconsole.toml [[users]] @@ -322,7 +319,7 @@ email = "alice@example.com" ### Owner Role -Users with `owner = true` can view subscription status and access upgrade options. See [Manage License](/configuration/license). If no user has `owner = true`, the first user entry automatically becomes the owner. +Users with `owner = true` are marked with an Owner badge in the UI. If no user has `owner = true`, the first user entry automatically becomes the owner. ## Groups @@ -345,7 +342,7 @@ members = ["admin@example.com", "alice@example.com"] ## Access Control (IAM) -Rules for controlling access to connections. See [Database Access Control](/features/database-access-control) for a full guide on permissions, patterns, and examples. +Rules for controlling access to connections. IAM is opt-in: with no `[[iam]]` rules defined, all authenticated users have full access, and enforcement begins once you define the first rule. See [Database Access Control](/features/database-access-control) for a full guide on permissions, patterns, and examples. | Field | Description | Required | |-------|-------------|----------| @@ -410,7 +407,7 @@ base_url = "https://api.groq.com/openai/v1" ## Agents -Non-human principals that authenticate to the [MCP Server](/features/mcp-server) with a bearer token. An agent is **not** a [user](#users) — it can't log into the UI and doesn't count against your license seats. Repeat for multiple agents. +Non-human principals that authenticate to the [MCP Server](/features/mcp-server) with a bearer token. An agent is **not** a [user](#users) — it can't log into the UI. Repeat for multiple agents. | Field | Description | Required | |-------|-------------|----------| diff --git a/docs/configuration/license.mdx b/docs/configuration/license.mdx deleted file mode 100644 index 88a78e5..0000000 --- a/docs/configuration/license.mdx +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: Manage License ---- - -pgconsole does not connect to any external license server, so it cannot auto-refresh the license. After purchasing a new subscription or renewing an existing one, a new license key will be emailed to you. Update the key in your configuration file and restart pgconsole for it to take effect. - -## Purchasing a License - -Purchase directly from the application via the **Subscription** option in the owner's profile menu, or use the links below: - -- [Monthly plan — $20/user/month](https://buy.stripe.com/aFa00ifoZ8hHfEDgeN1Fe02) -- [Annual plan — $16/user/month](https://buy.stripe.com/28E28qfoZ69zcsr2nX1Fe03) - -A license key will be emailed to you after purchase. - -## Adding a License - -Add your license key to the configuration file: - -```toml pgconsole.toml -[general] -license = "your-license-key" -``` - -Restart pgconsole for the license to take effect. - -## Billing - -For billing issues, contact [billing@pgconsole.com](mailto:billing@pgconsole.com). diff --git a/docs/docs.json b/docs/docs.json index 37878d4..f164c90 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -52,7 +52,6 @@ "pages": [ "configuration/server-flags", "configuration/external-access", - "configuration/license", "configuration/config" ] } diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index 5a171b7..7e0d493 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -2,8 +2,6 @@ title: Audit Log --- -This feature requires the **Enterprise** plan. - pgconsole emits audit logs as JSON lines to stdout, allowing you to capture and process them with your existing log infrastructure. ## Events diff --git a/docs/features/database-access-control.mdx b/docs/features/database-access-control.mdx index 2f4f3cd..4cbd677 100644 --- a/docs/features/database-access-control.mdx +++ b/docs/features/database-access-control.mdx @@ -2,10 +2,10 @@ title: Database Access Control --- -This feature requires the **Team** or **Enterprise** plan. - pgconsole provides fine-grained access control for your database connections. You define [IAM rules](/configuration/config#access-control-iam) in your configuration file, and pgconsole enforces them — rejecting unauthorized queries before they reach the database. +IAM is **opt-in**. With no `[[iam]]` rules defined, every authenticated user has full access to all connections. Enforcement begins the moment you define your first rule — from then on, any user without a matching rule is denied. + ```mermaid flowchart LR U[User] -->|SQL| P[pgconsole] @@ -19,17 +19,17 @@ Unlike PostgreSQL's built-in role system (`GRANT`/`REVOKE`), pgconsole's access ![IAM Permission Denied](/images/features/database-access-control/iam-permission-denied.webp) -Access control in pgconsole works on three principles: +Once at least one rule is defined, access control works on three principles: - **Default deny** — users have no access unless a rule explicitly grants it - **Connection-scoped** — permissions are granted per database connection, not globally - **Disjoint permissions** — each permission level is independent; `write` does not imply `read` -When IAM is enabled, users only see connections they have at least one permission for. Connections without any matching rules are hidden entirely. +While IAM is active, users only see connections they have at least one permission for. Connections without any matching rules are hidden entirely. ## Prerequisites -- [Authentication](/configuration/config#authentication) must be enabled; otherwise, all users get full access to all connections +- [Authentication](/configuration/config#authentication) must be enabled, and at least one `[[iam]]` rule must be defined; otherwise, all users get full access to all connections - Users must be defined in [`[[users]]`](/configuration/config#users) - Groups (if used) must be defined in [`[[groups]]`](/configuration/config#groups) diff --git a/docs/features/mcp-server.mdx b/docs/features/mcp-server.mdx index 16a990f..c39dc26 100644 --- a/docs/features/mcp-server.mdx +++ b/docs/features/mcp-server.mdx @@ -24,7 +24,7 @@ Requests without a valid token are rejected with `401`. ## Identity -Each token belongs to an `[[agents]]` entry — a non-human principal that is **not** a user (no UI login, no license seat). There are two kinds: +Each token belongs to an `[[agents]]` entry — a non-human principal that is **not** a user (no UI login). There are two kinds: - **Pure agent** — a standalone service account (e.g. a CI bot). Authorized by IAM rules whose `members` include `agent:`. Audited as `agent:`. - **Delegated agent** — acts `on_behalf_of` a user, **inheriting that user's permissions** narrowed by optional `permissions`/`connections` caps. It can never exceed the user and loses access automatically when the user does. Audited as the user, tagged with the agent. diff --git a/docs/features/white-labeling.mdx b/docs/features/white-labeling.mdx index 7624334..c3ec62e 100644 --- a/docs/features/white-labeling.mdx +++ b/docs/features/white-labeling.mdx @@ -2,8 +2,6 @@ title: White Labeling --- -This feature requires the **Enterprise** plan. - pgconsole supports white labeling, allowing you to rebrand and embed the console into your own product. - **Custom Logo** — Replace the default logo with your own in the top-right corner. diff --git a/docs/getting-started/faq.mdx b/docs/getting-started/faq.mdx index 6c4623d..87e8b91 100644 --- a/docs/getting-started/faq.mdx +++ b/docs/getting-started/faq.mdx @@ -20,7 +20,7 @@ No. pgconsole is fully self-hosted and makes no outbound network requests except ## License -pgconsole is [source available](https://github.com/pgplex/pgconsole/blob/main/LICENSE). The Community plan is free to use. A commercial license is required for the Team or Enterprise plan. +pgconsole is [source available](https://github.com/pgplex/pgconsole/blob/main/LICENSE) and free to use, with all features included. ## Certifications diff --git a/server/auth-routes.ts b/server/auth-routes.ts index 166c2b6..b364120 100644 --- a/server/auth-routes.ts +++ b/server/auth-routes.ts @@ -1,19 +1,11 @@ import { Router, type Request, type Response } from 'express' import crypto from 'crypto' import { createToken, verifyToken, authenticateBasic } from './lib/auth' -import { getAuthConfig, isAuthEnabled, getGroupsForUser, getPlan, isOwner, getUsers } from './lib/config' +import { getAuthConfig, isAuthEnabled, getGroupsForUser, isOwner, getUsers } from './lib/config' import { auditLogin, auditLogout } from './lib/audit' import { registerGoogleOAuth } from './lib/oauth/google' import { registerKeycloakOAuth } from './lib/oauth/keycloak' import { registerOktaOAuth } from './lib/oauth/okta' -import { feature, requiredPlan } from '../src/lib/plan' -import type { Feature } from '../src/lib/plan' - -const SSO_FEATURE: Record = { - google: 'SSO_GOOGLE', - keycloak: 'SSO_KEYCLOAK', - okta: 'SSO_OKTA', -} const router = Router() @@ -123,28 +115,6 @@ const oauthOpts = { generateState, getClientIp, } -// Gate OAuth routes by plan -router.use(['/google', '/google/callback'], (req: Request, res: Response, next) => { - if (!feature('SSO_GOOGLE', getPlan())) { - return res.status(403).json({ error: 'Google SSO requires Team plan or higher' }) - } - next() -}) - -router.use(['/keycloak', '/keycloak/callback'], (req: Request, res: Response, next) => { - if (!feature('SSO_KEYCLOAK', getPlan())) { - return res.status(403).json({ error: 'Keycloak SSO requires Enterprise plan' }) - } - next() -}) - -router.use(['/okta', '/okta/callback'], (req: Request, res: Response, next) => { - if (!feature('SSO_OKTA', getPlan())) { - return res.status(403).json({ error: 'Okta SSO requires Enterprise plan' }) - } - next() -}) - registerGoogleOAuth(router, oauthOpts) registerKeycloakOAuth(router, oauthOpts) registerOktaOAuth(router, oauthOpts) @@ -156,16 +126,10 @@ router.get('/providers', (_req: Request, res: Response) => { } const config = getAuthConfig() - const plan = getPlan() - const providers: Array<{ name: string; requiredPlan?: string }> = [] + const providers: Array<{ name: string }> = [] if (getUsers().some(u => u.password)) providers.push({ name: 'basic' }) for (const provider of config?.providers ?? []) { - const feat = SSO_FEATURE[provider.type] - if (feat && !feature(feat, plan)) { - providers.push({ name: provider.type, requiredPlan: requiredPlan(feat) }) - } else { - providers.push({ name: provider.type }) - } + providers.push({ name: provider.type }) } return res.json({ providers }) diff --git a/server/index.ts b/server/index.ts index 43fbb17..2c1a731 100644 --- a/server/index.ts +++ b/server/index.ts @@ -4,10 +4,9 @@ import cookieParser from 'cookie-parser' import { authRouter } from './auth-routes' import { connectRouter } from './connect' import { mcpRouter, MCP_PATH } from './mcp' -import { loadConfig, loadConfigFromString, loadDemoConfig, isDemoMode, getBanner, getBranding, getExternalUrl, getPlan, getLicenseExpiry, getUsers, getLicenseMaxUsers, getLicenseEmail, getAgents } from './lib/config' +import { loadConfig, loadConfigFromString, loadDemoConfig, isDemoMode, getBanner, getBranding, getExternalUrl, getAgents } from './lib/config' import { startDemoDatabase, stopDemoDatabase } from './lib/demo' import { testAllConnections } from './lib/test-connections' -import { feature } from '../src/lib/plan' // __dirname is provided by esbuild banner declare const __dirname: string @@ -40,15 +39,9 @@ app.use(connectRouter) // Public settings endpoint (no auth required) app.get('/api/setting', (_req, res) => { - const plan = getPlan() res.json({ - banner: feature('BANNER', plan) ? getBanner() : undefined, - branding: feature('BRANDING', plan) ? getBranding() : undefined, - plan, - licenseExpiry: getLicenseExpiry(), - licenseEmail: getLicenseEmail(), - maxUsers: getLicenseMaxUsers(), - userCount: getUsers().length, + banner: getBanner(), + branding: getBranding(), demo: isDemoMode(), }) }) @@ -99,21 +92,6 @@ async function start() { console.log(`✓ Demo database started on port ${demoPort}`) } - // Check license expiration - const licenseExpiry = getLicenseExpiry() - if (licenseExpiry) { - const now = Math.floor(Date.now() / 1000) - if (licenseExpiry < now) { - console.warn('\n⚠️ WARNING: Your license has expired. Some features may be restricted.\n Renew at https://docs.pgconsole.com/configuration/license#purchasing-a-license\n') - } - } - - // Log plan and user seat info - const plan = getPlan() - const maxUsers = getLicenseMaxUsers() - const userCount = getUsers().length - console.log(`✓ Plan: ${plan}, User seat: ${userCount}/${maxUsers}`) - // Test all connections to populate cache try { await testAllConnections() diff --git a/server/lib/audit.ts b/server/lib/audit.ts index d982155..23ba896 100644 --- a/server/lib/audit.ts +++ b/server/lib/audit.ts @@ -1,7 +1,4 @@ // Audit logging - emits JSON lines to stdout -import { feature } from '../../src/lib/plan' -import { getPlan } from './config' - interface BaseEvent { type: 'audit' ts: string @@ -49,7 +46,6 @@ interface DataExportEvent extends BaseEvent { type AuditEvent = AuthLoginEvent | AuthLogoutEvent | SQLExecuteEvent | DataExportEvent function emit(event: AuditEvent): void { - if (!feature('AUDIT_LOG', getPlan())) return console.log(JSON.stringify(event)) } diff --git a/server/lib/config.ts b/server/lib/config.ts index 556e3b9..ebd9169 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -1,8 +1,5 @@ import { parse } from 'smol-toml' import { readFileSync } from 'fs' -import { checkLicense } from './license' -import { feature } from '../../src/lib/plan' -import type { PlanTier } from '../../src/lib/plan' import type { Vendor } from '../ai/vendors' export interface LabelConfig { @@ -103,7 +100,6 @@ export interface IAMRule { interface Config { external_url?: string - license?: string banner?: BannerConfig branding?: BrandingConfig users: UserConfig[] @@ -114,10 +110,6 @@ interface Config { ai?: AIConfig agents: AgentConfig[] iam: IAMRule[] - plan: PlanTier - licenseExpiry?: number - licenseMaxUsers: number - licenseEmail?: string } const validSslModes = ['disable', 'prefer', 'require', 'verify-full'] @@ -144,7 +136,7 @@ function parsePermissionList(raw: unknown, label: string): Permission[] { return permissions } -const DEFAULT_CONFIG: Config = { users: [], groups: [], labels: [], connections: [], auth: undefined, ai: undefined, agents: [], banner: undefined, branding: undefined, license: undefined, iam: [], plan: 'FREE', licenseExpiry: undefined, licenseMaxUsers: 1, licenseEmail: undefined } +const DEFAULT_CONFIG: Config = { users: [], groups: [], labels: [], connections: [], auth: undefined, ai: undefined, agents: [], banner: undefined, branding: undefined, iam: [] } let loadedConfig: Config = { ...DEFAULT_CONFIG } let demoMode = false @@ -183,7 +175,6 @@ export async function loadConfigFromString(content: string): Promise { // Parse [general] section let external_url: string | undefined = undefined - let license: string | undefined = undefined let banner: BannerConfig | undefined = undefined if (parsed.general) { const g = parsed.general @@ -201,13 +192,6 @@ export async function loadConfigFromString(content: string): Promise { } } - if (g.license !== undefined) { - if (typeof g.license !== 'string') { - throw new Error('general.license must be a string') - } - license = g.license - } - // Parse [general.banner] section const rawBanner = g.banner as Record | undefined if (rawBanner) { @@ -763,28 +747,7 @@ export async function loadConfigFromString(content: string): Promise { iam.push({ connection, permissions, members }) } - // Resolve license → plan tier and maxUsers before assigning config - let plan: PlanTier = 'FREE' - let licenseExpiry: number | undefined - let licenseMaxUsers = 1 - let licenseEmail: string | undefined - if (license) { - const result = await checkLicense(license) - plan = result.plan - licenseExpiry = result.expiry - licenseMaxUsers = result.maxUsers - licenseEmail = result.email - } - - loadedConfig = { external_url, license, banner, branding, users, groups, labels, connections, auth, ai, agents, iam, plan, licenseExpiry, licenseMaxUsers, licenseEmail } - - // Validate user count against license limit - if (auth) { - const limit = licenseMaxUsers - if (users.length > limit) { - throw new Error(`Too many [[users]] entries: ${users.length} configured but current license only allows ${limit}. Remove users or upgrade at https://docs.pgconsole.com/configuration/license#purchasing-a-license`) - } - } + loadedConfig = { external_url, banner, branding, users, groups, labels, connections, auth, ai, agents, iam } } export function getLabels(): LabelConfig[] { @@ -800,7 +763,6 @@ export function getGroupById(id: string): GroupConfig | undefined { } export function getGroupsForUser(email: string): GroupConfig[] { - if (!feature('GROUPS', getPlan())) return [] return loadedConfig.groups.filter((g) => g.members.includes(email)) } @@ -877,26 +839,6 @@ export function getIAMRules(): IAMRule[] { return loadedConfig.iam } -export function getLicense(): string | undefined { - return loadedConfig.license -} - -export function getPlan(): PlanTier { - return loadedConfig.plan -} - -export function getLicenseExpiry(): number | undefined { - return loadedConfig.licenseExpiry -} - -export function getLicenseMaxUsers(): number { - return loadedConfig.licenseMaxUsers -} - -export function getLicenseEmail(): string | undefined { - return loadedConfig.licenseEmail -} - export function loadDemoConfig(port: number): void { demoMode = true loadedConfig = { diff --git a/server/lib/iam.ts b/server/lib/iam.ts index c951d8b..6745136 100644 --- a/server/lib/iam.ts +++ b/server/lib/iam.ts @@ -1,6 +1,5 @@ import { ConnectError, Code } from '@connectrpc/connect' -import { getIAMRules, getGroupsForUser, isAuthEnabled, getPlan } from './config' -import { feature } from '../../src/lib/plan' +import { getIAMRules, getGroupsForUser, isAuthEnabled } from './config' import type { Permission, AgentConfig, IAMRule } from './config' export type { Permission } @@ -63,13 +62,15 @@ export function requireAnyPermission( } // Union the permissions from every IAM rule that applies to this connection and whose -// members satisfy `matches`. Auth-disabled / IAM-not-licensed short-circuit to full access. +// members satisfy `matches`. IAM is opt-in: with auth disabled or no [[iam]] rules +// defined, every principal gets full access. Defining the first rule turns enforcement on. function resolvePermissions(connectionId: string, matches: (rule: IAMRule) => boolean): Set { - if (!isAuthEnabled() || !feature('IAM', getPlan())) { + const rules = getIAMRules() + if (!isAuthEnabled() || rules.length === 0) { return new Set(ALL_PERMISSIONS) } const permissions = new Set() - for (const rule of getIAMRules()) { + for (const rule of rules) { if (rule.connection !== '*' && rule.connection !== connectionId) { continue } @@ -88,7 +89,7 @@ function resolvePermissions(connectionId: string, matches: (rule: IAMRule) => bo */ export function getUserPermissions(email: string, connectionId: string): Set { // Resolve the user's groups lazily — skipped entirely when resolvePermissions - // short-circuits (auth off / IAM unlicensed) or when no rule has a group: member. + // short-circuits (auth off / no IAM rules) or when no rule has a group: member. let groupIds: Set | undefined return resolvePermissions(connectionId, rule => rule.members.some(member => { @@ -112,9 +113,8 @@ export function hasPermission(email: string, connectionId: string, permission: P /** * Get an agent's effective permissions for a connection. Like `getUserPermissions`, this - * grants full access when auth is disabled or the plan doesn't include IAM (the shared - * `resolvePermissions` short-circuit — IAM is a paid feature, applied uniformly to every - * principal). When IAM is active: + * grants full access when auth is disabled or no IAM rules are defined (the shared + * `resolvePermissions` short-circuit). Once IAM is active: * - Pure agent: matched ONLY by explicit `agent:` rules — `*`/`group:`/`user:` rules * never apply, so no agent silently inherits a broad "everyone" grant. * - Delegated agent (`onBehalfOf`): the user's grant, narrowed by the connection/permission diff --git a/server/lib/license.ts b/server/lib/license.ts deleted file mode 100644 index 31f8470..0000000 --- a/server/lib/license.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { jwtVerify, importSPKI } from 'jose' -import type { PlanTier } from '../../src/lib/plan' - -export interface LicenseResult { - plan: PlanTier - expiry?: number - maxUsers: number - email?: string -} - -const LICENSE_ISSUER = 'pgconsole/license' - -let KEYGEN_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuNGFvPtpvyhT7eYc1x5Y -ir/nW5CAH3kLYL3F70xN5bMYxsx9h9H/4xBIAk3ddm/maOqcua2E+PI2Z1w8lEtR -GhAXSJtykKGuPXIDudnMVXbYqYhvvVYTH4NXRtTuS5NdTD0ZURr6X8X01dIsJVve -QUp/TXODV0GHTRvkRdisak3NUnub9Mv20XirYWPed1OnDWLuE57T1FaD6ZYhC0is -loNCg5i7KfmNxigW/iABe7Pbvafuq5O5UBkN9l7x+kcnc68oY/ceBUnyHa8Hj3p6 -B5IyYLTs5y7dD1IS22hJiteOQEmmdOQpYCXyOhVRXcuVHzHYkQNNvlW9vDVTE+m7 -rQIDAQAB ------END PUBLIC KEY-----` - -export function setPublicKeyForTesting(pem: string): void { - KEYGEN_PUBLIC_KEY = pem -} - -export async function checkLicense(license: string): Promise { - try { - const publicKey = await importSPKI(KEYGEN_PUBLIC_KEY, 'RS256') - const { payload } = await jwtVerify(license, publicKey, { - issuer: LICENSE_ISSUER, - requiredClaims: ['exp'], - }) - const plan = payload.plan as string - const maxUsers = typeof payload.userSeat === 'number' ? payload.userSeat : 1 - const email = typeof payload.email === 'string' ? payload.email : undefined - if (plan === 'team' || plan === 'enterprise') { - return { plan: plan.toUpperCase() as PlanTier, expiry: payload.exp, maxUsers, email } - } - console.warn('License JWT has unrecognized plan claim:', plan) - return { plan: 'FREE', maxUsers: 1 } - } catch (err) { - console.warn('License verification failed:', err instanceof Error ? err.message : err) - return { plan: 'FREE', maxUsers: 1 } - } -} diff --git a/src/App.tsx b/src/App.tsx index b2a8195..77bb180 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,8 +12,6 @@ import { useEditorNavigation } from './hooks/useEditorNavigation'; import { Button } from './components/ui/button'; import { Banner } from './components/Banner'; import { useSetting } from './hooks/useSetting'; -import { SubscriptionModalProvider } from './components/SubscriptionModal'; -import { LicenseExpiryBanner } from './components/LicenseExpiryBanner'; import { DemoBanner } from './components/DemoBanner'; function AppLayout() { @@ -103,13 +101,11 @@ function AppLayout() { return ( - -
+
{!isSignInRoute && demo && } {!isSignInRoute && banner?.text && ( )} - {!isSignInRoute && } {!isSignInRoute &&
}
@@ -139,8 +135,7 @@ function AppLayout() { } />
-
- +
); } diff --git a/src/components/AuthForm.tsx b/src/components/AuthForm.tsx index d0b7cc1..ee2f09c 100644 --- a/src/components/AuthForm.tsx +++ b/src/components/AuthForm.tsx @@ -46,7 +46,7 @@ export default function AuthForm({ onSuccess }: AuthFormProps) { const hasBasic = providers.some((p) => p.name === 'basic'); const oauthEntries = providers .filter((p) => p.name !== 'basic' && p.name in oauthProviders) - .map((p) => ({ key: p.name, requiredPlan: p.requiredPlan, ...oauthProviders[p.name] })); + .map((p) => ({ key: p.name, ...oauthProviders[p.name] })); const hasOAuth = oauthEntries.length > 0; if (providers.length === 0) { @@ -55,23 +55,17 @@ export default function AuthForm({ onSuccess }: AuthFormProps) { return (
- {oauthEntries.map(({ key, icon, label, path, requiredPlan }) => ( + {oauthEntries.map(({ key, icon, label, path }) => (
- {requiredPlan && ( -

- Requires {requiredPlan} plan — Get a license -

- )}
))} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 5517247..343ea58 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,9 +1,8 @@ import { useNavigate } from 'react-router-dom'; -import { LogOut, CreditCard } from 'lucide-react'; +import { LogOut } from 'lucide-react'; import { ConnectionSwitcher } from './ConnectionSwitcher'; import { Menu, MenuTrigger, MenuPopup, MenuItem } from './ui/menu'; import { useSession, signOut } from '@/lib/auth-client'; -import { useSubscriptionModal } from '@/hooks/useSubscriptionModal'; import { useOwner } from '@/hooks/useOwner'; import { useSetting } from '@/hooks/useSetting'; import { useConnections } from '@/hooks/useQuery'; @@ -26,7 +25,6 @@ function expandHex(hex: string): string { export default function Header({ selectedConnectionId }: HeaderProps) { const navigate = useNavigate(); const { user, authEnabled } = useSession(); - const subscriptionModal = useSubscriptionModal(); const isOwner = useOwner(); const { branding } = useSetting(); const { data: connections } = useConnections(); @@ -91,12 +89,6 @@ export default function Header({ selectedConnectionId }: HeaderProps) { {isOwner && (Owner)}
- {isOwner && ( - subscriptionModal.open()}> - - Subscription - - )} Sign Out diff --git a/src/components/LicenseExpiryBanner.tsx b/src/components/LicenseExpiryBanner.tsx deleted file mode 100644 index 8a91f6e..0000000 --- a/src/components/LicenseExpiryBanner.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useSetting } from '@/hooks/useSetting' -import { useOwner } from '@/hooks/useOwner' -import { useSubscriptionModal } from '@/hooks/useSubscriptionModal' - -// Number of days before expiry to show warning banner -const EXPIRY_WARNING_DAYS = 7 - -export function LicenseExpiryBanner() { - const { licenseExpiry } = useSetting() - const isOwner = useOwner() - const subscriptionModal = useSubscriptionModal() - - if (!licenseExpiry) return null - - const now = Date.now() - const expiryMs = licenseExpiry * 1000 - const daysUntilExpiry = Math.ceil((expiryMs - now) / (1000 * 60 * 60 * 24)) - - // Don't show if not expiring soon (expired = negative days, so always shows) - if (daysUntilExpiry > EXPIRY_WARNING_DAYS) return null - - const message = - daysUntilExpiry < 0 - ? 'Your license has expired.' - : daysUntilExpiry === 0 - ? 'Your license expires today.' - : daysUntilExpiry === 1 - ? 'Your license expires tomorrow.' - : `Your license expires in ${daysUntilExpiry} days.` - - if (isOwner) { - return ( - - ) - } - - return ( -
- {message} -
- ) -} diff --git a/src/components/SubscriptionModal.tsx b/src/components/SubscriptionModal.tsx deleted file mode 100644 index fd973dd..0000000 --- a/src/components/SubscriptionModal.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useState, useCallback, useMemo } from 'react' -import { Users, Calendar, Shield, Mail } from 'lucide-react' -import { useOwner } from '@/hooks/useOwner' -import { - Dialog, - DialogPopup, - DialogHeader, - DialogTitle, - DialogDescription, - DialogPanel, - DialogFooter, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { useSetting } from '@/hooks/useSetting' -import { SubscriptionModalContext } from '@/hooks/useSubscriptionModal' -import type { PlanTier } from '@/lib/plan' - -const PLAN_LABELS: Record = { - FREE: 'Free', - TEAM: 'Team', - ENTERPRISE: 'Enterprise', -} - -const PLAN_COLORS: Record = { - FREE: 'bg-gray-100 text-gray-700', - TEAM: 'bg-blue-100 text-blue-700', - ENTERPRISE: 'bg-purple-100 text-purple-700', -} - -const PAYMENT_LINKS = { - monthly: 'https://buy.stripe.com/aFa00ifoZ8hHfEDgeN1Fe02', - annual: 'https://buy.stripe.com/28E28qfoZ69zcsr2nX1Fe03', -} - -const CUSTOMER_PORTAL_LINK = 'https://billing.stripe.com/p/login/8x23cu1y969z643e6F1Fe00' - -function formatDate(timestamp: number): string { - return new Date(timestamp * 1000).toLocaleDateString(undefined, { - year: 'numeric', - month: 'long', - day: 'numeric', - }) -} - -function isExpired(timestamp: number): boolean { - return timestamp * 1000 < Date.now() -} - -type BillingCycle = 'monthly' | 'annual' - -export function SubscriptionModalProvider({ children }: { children: React.ReactNode }) { - const [isOpen, setIsOpen] = useState(false) - const [reason, setReason] = useState() - const { plan, licenseExpiry, licenseEmail, maxUsers, userCount } = useSetting() - const isOwner = useOwner() - const [billingCycle, setBillingCycle] = useState('annual') - - const open = useCallback((r?: string) => { - setReason(r) - setIsOpen(true) - }, []) - - const contextValue = useMemo(() => ({ open }), [open]) - - const expired = licenseExpiry ? isExpired(licenseExpiry) : false - - return ( - - {children} - - - - Subscription - {reason && ( - {reason} - )} - - -
-
-
- - Plan -
- {PLAN_LABELS[plan]} -
- - {licenseEmail && ( -
-
- - Licensee -
- {licenseEmail} -
- )} - - {licenseExpiry && ( -
-
- - Expires -
- - {expired ? 'Expired' : formatDate(licenseExpiry)} - -
- )} - -
-
- - Users -
- - {userCount} / {maxUsers} - -
- - {isOwner && plan === 'FREE' && ( -
-

Select billing cycle:

-
- - -
-
- )} -
-
- - - {isOwner && ( - - )} - -
-
-
- ) -} diff --git a/src/hooks/useSetting.ts b/src/hooks/useSetting.ts index a4a08d6..0160b18 100644 --- a/src/hooks/useSetting.ts +++ b/src/hooks/useSetting.ts @@ -1,5 +1,4 @@ import { useQuery } from '@tanstack/react-query' -import type { PlanTier } from '@/lib/plan' interface BannerConfig { text: string @@ -15,11 +14,6 @@ interface BrandingConfig { interface SettingResponse { branding?: BrandingConfig banner?: BannerConfig - plan: PlanTier - licenseExpiry?: number - licenseEmail?: string - maxUsers: number - userCount: number demo?: boolean } @@ -36,11 +30,6 @@ export function useSetting() { return { branding: data?.branding, banner: data?.banner, - plan: data?.plan ?? 'FREE', - licenseExpiry: data?.licenseExpiry, - licenseEmail: data?.licenseEmail, - maxUsers: data?.maxUsers ?? 1, - userCount: data?.userCount ?? 0, demo: data?.demo ?? false, isLoading, } diff --git a/src/hooks/useSubscriptionModal.ts b/src/hooks/useSubscriptionModal.ts deleted file mode 100644 index 406227f..0000000 --- a/src/hooks/useSubscriptionModal.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext, useContext } from 'react' - -export interface SubscriptionModalContextValue { - open: (reason?: string) => void -} - -export const SubscriptionModalContext = createContext(null) - -export function useSubscriptionModal(): SubscriptionModalContextValue { - const context = useContext(SubscriptionModalContext) - if (!context) { - throw new Error('useSubscriptionModal must be used within SubscriptionModalProvider') - } - return context -} diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 6912f50..d2e9011 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -80,7 +80,6 @@ export async function signOut() { export interface AuthProvider { name: string - requiredPlan?: string } export async function getProviders(): Promise { diff --git a/src/lib/plan.ts b/src/lib/plan.ts deleted file mode 100644 index a05b5f7..0000000 --- a/src/lib/plan.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type PlanTier = 'FREE' | 'TEAM' | 'ENTERPRISE' - -const PLAN_ORDER: Record = { - FREE: 0, - TEAM: 1, - ENTERPRISE: 2, -} - -const FEATURE_PLAN = { - GROUPS: 'TEAM', - IAM: 'TEAM', - SSO_GOOGLE: 'TEAM', - SSO_KEYCLOAK: 'ENTERPRISE', - SSO_OKTA: 'ENTERPRISE', - BANNER: 'TEAM', - BRANDING: 'ENTERPRISE', - AUDIT_LOG: 'ENTERPRISE', -} as const - -export type Feature = keyof typeof FEATURE_PLAN - -export function feature(name: Feature, plan: PlanTier): boolean { - return PLAN_ORDER[plan] >= PLAN_ORDER[FEATURE_PLAN[name]] -} - -export function requiredPlan(name: Feature): PlanTier { - return FEATURE_PLAN[name] -} diff --git a/tests/iam.test.ts b/tests/iam.test.ts index 20dc606..dc68ee4 100644 --- a/tests/iam.test.ts +++ b/tests/iam.test.ts @@ -1,25 +1,17 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { getUserPermissions, hasPermission, getAccessibleConnectionIds, getAgentPermissions } from '../server/lib/iam' import * as config from '../server/lib/config' -import { feature as featureCheck } from '../src/lib/plan' // Mock config module vi.mock('../server/lib/config', () => ({ getIAMRules: vi.fn(), getGroupsForUser: vi.fn(), isAuthEnabled: vi.fn(), - getPlan: vi.fn(), -})) - -vi.mock('../src/lib/plan', () => ({ - feature: vi.fn(), })) describe('getUserPermissions', () => { beforeEach(() => { vi.resetAllMocks() - // Default: IAM feature enabled (existing tests assume this) - vi.mocked(featureCheck).mockReturnValue(true) }) it('returns all permissions when auth is disabled', () => { @@ -31,11 +23,22 @@ describe('getUserPermissions', () => { expect(perms).toEqual(new Set(['read', 'write', 'ddl', 'admin', 'explain', 'execute', 'export'])) }) - it('returns empty set when no rules match', () => { + it('returns all permissions when no IAM rules are defined (IAM off by default)', () => { vi.mocked(config.isAuthEnabled).mockReturnValue(true) vi.mocked(config.getIAMRules).mockReturnValue([]) vi.mocked(config.getGroupsForUser).mockReturnValue([]) + const perms = getUserPermissions('alice', 'prod') + expect(perms).toEqual(new Set(['read', 'write', 'ddl', 'admin', 'explain', 'execute', 'export'])) + }) + + it('returns empty set when rules are defined but none match', () => { + vi.mocked(config.isAuthEnabled).mockReturnValue(true) + vi.mocked(config.getIAMRules).mockReturnValue([ + { connection: 'prod', permissions: ['read'], members: ['user:someone-else'] }, + ]) + vi.mocked(config.getGroupsForUser).mockReturnValue([]) + const perms = getUserPermissions('alice', 'prod') expect(perms).toEqual(new Set()) }) @@ -118,23 +121,11 @@ describe('getUserPermissions', () => { const perms = getUserPermissions('alice', 'prod') expect(perms).toEqual(new Set(['read', 'write'])) }) - - it('returns all permissions when IAM feature is not enabled by plan', () => { - vi.mocked(config.isAuthEnabled).mockReturnValue(true) - vi.mocked(featureCheck).mockReturnValue(false) - vi.mocked(config.getIAMRules).mockReturnValue([]) - vi.mocked(config.getGroupsForUser).mockReturnValue([]) - - const perms = getUserPermissions('alice', 'prod') - expect(perms).toEqual(new Set(['read', 'write', 'ddl', 'admin', 'explain', 'execute', 'export'])) - }) }) describe('hasPermission', () => { beforeEach(() => { vi.resetAllMocks() - // Default: IAM feature enabled (existing tests assume this) - vi.mocked(featureCheck).mockReturnValue(true) }) it('returns true when user has the permission', () => { @@ -153,7 +144,6 @@ describe('hasPermission', () => { describe('getAgentPermissions', () => { beforeEach(() => { vi.resetAllMocks() - vi.mocked(featureCheck).mockReturnValue(true) vi.mocked(config.isAuthEnabled).mockReturnValue(true) vi.mocked(config.getGroupsForUser).mockReturnValue([]) }) @@ -211,8 +201,6 @@ describe('getAgentPermissions', () => { describe('getAccessibleConnectionIds', () => { beforeEach(() => { vi.resetAllMocks() - // Default: IAM feature enabled (existing tests assume this) - vi.mocked(featureCheck).mockReturnValue(true) }) it('returns all connections when auth is disabled', () => { @@ -236,9 +224,11 @@ describe('getAccessibleConnectionIds', () => { expect(result).toEqual(['conn1', 'conn3']) }) - it('returns empty array when no permissions', () => { + it('returns empty array when rules are defined but none match', () => { vi.mocked(config.isAuthEnabled).mockReturnValue(true) - vi.mocked(config.getIAMRules).mockReturnValue([]) + vi.mocked(config.getIAMRules).mockReturnValue([ + { connection: 'conn1', permissions: ['read'], members: ['user:someone-else'] }, + ]) vi.mocked(config.getGroupsForUser).mockReturnValue([]) const result = getAccessibleConnectionIds('alice', ['conn1', 'conn2']) diff --git a/tests/license.test.ts b/tests/license.test.ts deleted file mode 100644 index 3df4852..0000000 --- a/tests/license.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest' -import { SignJWT, exportSPKI, generateKeyPair } from 'jose' -import { checkLicense, setPublicKeyForTesting } from '../server/lib/license' - -let privateKey: CryptoKey -let publicKey: CryptoKey - -async function mintJWT(claims: Record, opts?: { key?: CryptoKey; expiresIn?: string }) { - const key = opts?.key ?? privateKey - let builder = new SignJWT(claims) - .setProtectedHeader({ alg: 'RS256' }) - .setIssuer('pgconsole/license') - .setSubject('lic_test-uuid') - if (opts?.expiresIn) { - builder = builder.setExpirationTime(opts.expiresIn) - } else { - builder = builder.setExpirationTime('1h') - } - return builder.sign(key) -} - -describe('checkLicense', () => { - beforeAll(async () => { - const pair = await generateKeyPair('RS256') - privateKey = pair.privateKey - publicKey = pair.publicKey - const pem = await exportSPKI(publicKey) - setPublicKeyForTesting(pem) - }) - - it('returns TEAM for valid TEAM license', async () => { - const jwt = await mintJWT({ plan: 'team', email: 'a@b.com' }) - const result = await checkLicense(jwt) - expect(result.plan).toBe('TEAM') - expect(result.expiry).toBeTypeOf('number') - }) - - it('returns ENTERPRISE for valid ENTERPRISE license', async () => { - const jwt = await mintJWT({ plan: 'enterprise', email: 'a@b.com' }) - expect((await checkLicense(jwt)).plan).toBe('ENTERPRISE') - }) - - it('returns FREE for expired license', async () => { - const jwt = await mintJWT({ plan: 'team', email: 'a@b.com' }, { expiresIn: '-1s' }) - expect((await checkLicense(jwt)).plan).toBe('FREE') - }) - - it('returns FREE for invalid signature', async () => { - const wrongPair = await generateKeyPair('RS256') - const jwt = await mintJWT({ plan: 'team', email: 'a@b.com' }, { key: wrongPair.privateKey }) - expect((await checkLicense(jwt)).plan).toBe('FREE') - }) - - it('returns FREE for unknown plan value', async () => { - const jwt = await mintJWT({ plan: 'BOGUS', email: 'a@b.com' }) - expect((await checkLicense(jwt)).plan).toBe('FREE') - }) - - it('returns FREE for missing plan claim', async () => { - const jwt = await mintJWT({ email: 'a@b.com' }) - expect((await checkLicense(jwt)).plan).toBe('FREE') - }) - - it('returns FREE for garbage string', async () => { - expect((await checkLicense('not-a-jwt')).plan).toBe('FREE') - }) - - it('returns FREE for empty string', async () => { - expect((await checkLicense('')).plan).toBe('FREE') - }) - - // maxUsers tests - it('returns maxUsers from user claim', async () => { - const jwt = await mintJWT({ plan: 'team', userSeat: 5 }) - const result = await checkLicense(jwt) - expect(result.maxUsers).toBe(5) - }) - - it('returns maxUsers=1 when user claim is missing', async () => { - const jwt = await mintJWT({ plan: 'team' }) - const result = await checkLicense(jwt) - expect(result.maxUsers).toBe(1) - }) - - it('returns maxUsers=1 for invalid license', async () => { - const result = await checkLicense('invalid-jwt') - expect(result.plan).toBe('FREE') - expect(result.maxUsers).toBe(1) - }) - - it('returns maxUsers=1 when user claim is non-numeric string', async () => { - const jwt = await mintJWT({ plan: 'team', userSeat: 'five' }) - const result = await checkLicense(jwt) - expect(result.maxUsers).toBe(1) - }) - - it('returns maxUsers=0 when user claim is zero', async () => { - const jwt = await mintJWT({ plan: 'team', userSeat: 0 }) - const result = await checkLicense(jwt) - expect(result.maxUsers).toBe(0) - }) - - it('returns negative maxUsers when user claim is negative', async () => { - const jwt = await mintJWT({ plan: 'team', userSeat: -1 }) - const result = await checkLicense(jwt) - expect(result.maxUsers).toBe(-1) - }) - - // email tests - it('returns email from email claim', async () => { - const jwt = await mintJWT({ plan: 'team', email: 'customer@example.com' }) - const result = await checkLicense(jwt) - expect(result.email).toBe('customer@example.com') - }) - - it('returns undefined email when email claim is missing', async () => { - const jwt = await mintJWT({ plan: 'team' }) - const result = await checkLicense(jwt) - expect(result.email).toBeUndefined() - }) -}) diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 11504ae..85492b9 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -127,8 +127,8 @@ members = ["${member}"] }) describe('Principal permission resolution', () => { - // On the FREE plan IAM is not enforced, so getUserPermissions returns the full set — - // which lets us verify that a delegated agent's caps actually narrow that base. + // With no [[iam]] rules defined, IAM is off and alice's base is full access — so the + // delegated agent's caps are what narrow it. it('delegated caps narrow the user grant (permission cap)', async () => { await loadConfigFromString(`${BASE} [[agents]] @@ -142,19 +142,6 @@ permissions = ["read", "explain"] expect(p.auditActor).toBe('alice@example.com') }) - it('delegated connection cap blocks other connections', async () => { - await loadConfigFromString(`${BASE} -[[agents]] -id = "alice-claude" -token = "t" -on_behalf_of = "alice@example.com" -connections = ["prod"] -`) - const p = new Principal(getAgentByToken('t')!) - expect(p.permissions('prod').size).toBeGreaterThan(0) - expect(p.permissions('staging')).toEqual(new Set()) - }) - it('a pure agent audits as agent:', async () => { await loadConfigFromString(`${BASE} [[agents]] @@ -167,8 +154,9 @@ token = "t" describe('dispatchTool enforcement', () => { // These exercise the permission/per-statement gating, which throws before any DB I/O. - // On the FREE plan a pure agent has all permissions, so rejections come purely from the - // tool's statement-kind rule; a delegated read-only agent exercises the cap-based denials. + // With no [[iam]] rules defined, IAM is off so the pure agent and alice have full access; + // rejections come purely from the tool's statement-kind rule, and a delegated read-only + // agent exercises the cap-based denials. const AGENTS = ` [[agents]] id = "pure" diff --git a/tests/plan.test.ts b/tests/plan.test.ts deleted file mode 100644 index 55e22cc..0000000 --- a/tests/plan.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { feature, requiredPlan } from '../src/lib/plan' - -describe('feature', () => { - it('FREE plan has no gated features', () => { - expect(feature('GROUPS', 'FREE')).toBe(false) - expect(feature('IAM', 'FREE')).toBe(false) - expect(feature('SSO_GOOGLE', 'FREE')).toBe(false) - expect(feature('SSO_KEYCLOAK', 'FREE')).toBe(false) - expect(feature('SSO_OKTA', 'FREE')).toBe(false) - expect(feature('BANNER', 'FREE')).toBe(false) - expect(feature('AUDIT_LOG', 'FREE')).toBe(false) - }) - - it('TEAM plan includes TEAM features', () => { - expect(feature('GROUPS', 'TEAM')).toBe(true) - expect(feature('IAM', 'TEAM')).toBe(true) - expect(feature('SSO_GOOGLE', 'TEAM')).toBe(true) - expect(feature('BANNER', 'TEAM')).toBe(true) - }) - - it('TEAM plan excludes ENTERPRISE features', () => { - expect(feature('SSO_KEYCLOAK', 'TEAM')).toBe(false) - expect(feature('SSO_OKTA', 'TEAM')).toBe(false) - expect(feature('AUDIT_LOG', 'TEAM')).toBe(false) - }) - - it('ENTERPRISE plan includes all features', () => { - expect(feature('GROUPS', 'ENTERPRISE')).toBe(true) - expect(feature('IAM', 'ENTERPRISE')).toBe(true) - expect(feature('SSO_GOOGLE', 'ENTERPRISE')).toBe(true) - expect(feature('SSO_KEYCLOAK', 'ENTERPRISE')).toBe(true) - expect(feature('SSO_OKTA', 'ENTERPRISE')).toBe(true) - expect(feature('BANNER', 'ENTERPRISE')).toBe(true) - expect(feature('AUDIT_LOG', 'ENTERPRISE')).toBe(true) - }) -}) - -describe('requiredPlan', () => { - it('returns minimum plan for each feature', () => { - expect(requiredPlan('GROUPS')).toBe('TEAM') - expect(requiredPlan('IAM')).toBe('TEAM') - expect(requiredPlan('SSO_GOOGLE')).toBe('TEAM') - expect(requiredPlan('SSO_KEYCLOAK')).toBe('ENTERPRISE') - expect(requiredPlan('SSO_OKTA')).toBe('ENTERPRISE') - expect(requiredPlan('BANNER')).toBe('TEAM') - expect(requiredPlan('AUDIT_LOG')).toBe('ENTERPRISE') - }) -}) - diff --git a/website/src/app/pricing/page.tsx b/website/src/app/pricing/page.tsx deleted file mode 100644 index c145eea..0000000 --- a/website/src/app/pricing/page.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { SoftButtonLink } from '@/components/elements/button' -import { Main } from '@/components/elements/main' -import { LINKS } from '@/lib/links' -import { FAQsTwoColumnAccordion, Faq } from '@/components/sections/faqs-two-column-accordion' -import { Plan, PricingHeroMultiTier } from '@/components/sections/pricing-hero-multi-tier' -import { CTA } from '@/components/shared/cta' -import { Footer } from '@/components/shared/footer' -import { Navbar } from '@/components/shared/navbar' - -function plans(option: string) { - return ( - <> - For individuals and small projects getting started

} - features={['1 user', 'Unlimited connections', 'SQL editor and intellisense', 'Inline data editing', 'Schema inspection', 'AI assistant']} - cta={ - - Get started - - } - /> - For teams who need collaboration

} - features={[ - 'Everything in Personal', - 'Unlimited users', - 'User groups', - 'Database access control', - 'Google SSO', - 'Custom banner', - ]} - cta={ - - Get started - - } - /> - For organizations that need security and governance

} - features={[ - 'Everything in Team', - 'Enterprise SSO (Okta, Keycloak, etc.)', - 'Audit logging', - 'OEM / White-labeling', - 'SOC 2 report', - 'Dedicated support channel', - ]} - cta={ - - Contact us - - } - /> - - ) -} - -export default function PricingPage() { - return ( - <> - - -
- {/* Pricing */} - - Start in minutes and scale as you grow. -

- } - options={['Monthly', 'Yearly']} - plans={{ Monthly: plans('Monthly'), Yearly: plans('Yearly') }} - /> - - {/* FAQs */} - - - - - - - - {/* Call To Action */} - -
- -