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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/api/migrations/008_add_lifetime_support.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
-- Add plan_type column (default to 'monthly' for existing subscriptions)
ALTER TABLE licenses ADD COLUMN plan_type TEXT NOT NULL DEFAULT 'monthly';

-- Add stripe_session_id column (unique, for lifetime idempotency)
ALTER TABLE licenses ADD COLUMN stripe_session_id TEXT UNIQUE;
-- Add stripe_session_id column (for lifetime idempotency)
ALTER TABLE licenses ADD COLUMN stripe_session_id TEXT;

-- Add status tracking columns
ALTER TABLE licenses ADD COLUMN canceled_at TEXT;
Expand All @@ -21,4 +21,4 @@ ALTER TABLE licenses ADD COLUMN revoked_reason TEXT;

-- Create indexes for new columns
CREATE INDEX IF NOT EXISTS licenses_plan_type_idx ON licenses(plan_type);
CREATE INDEX IF NOT EXISTS licenses_session_id_idx ON licenses(stripe_session_id);
CREATE UNIQUE INDEX IF NOT EXISTS licenses_session_id_idx ON licenses(stripe_session_id);
22 changes: 22 additions & 0 deletions apps/api/migrations/010_fingerprint_type.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- ============================================================================
-- Migration: Add fingerprint type support to license_machines
-- ============================================================================

-- Add fingerprint_type column with default 'machine'
ALTER TABLE license_machines ADD COLUMN fingerprint_type TEXT DEFAULT 'machine';

-- Add CI metadata columns
ALTER TABLE license_machines ADD COLUMN ci_provider TEXT;
ALTER TABLE license_machines ADD COLUMN ci_repo TEXT;

-- Add container metadata
ALTER TABLE license_machines ADD COLUMN container_type TEXT;

-- Create index for CI lookups
CREATE INDEX IF NOT EXISTS idx_license_machines_ci
ON license_machines(license_id, ci_provider, ci_repo)
WHERE ci_provider IS NOT NULL;

-- Create index for fingerprint type queries
CREATE INDEX IF NOT EXISTS idx_license_machines_fp_type
ON license_machines(license_id, fingerprint_type);
54 changes: 54 additions & 0 deletions apps/api/src/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
* - SOVEREIGN ($2,500): SSO, signed reports, air-gap, white-labeling
*/

import type { SecureLicenseLimits } from './lib/ed25519';

// =============================================================================
// Feature Enum
// =============================================================================
Expand Down Expand Up @@ -224,6 +226,7 @@ export const PLAN_LIMITS: Record<Plan, Record<string, number>> = {
aws_accounts: 1,
machines: 1,
history_retention_days: 7,
offline_grace_days: 0, // Must be online
},

// PRO TIER ($29/mo)
Expand All @@ -245,6 +248,7 @@ export const PLAN_LIMITS: Record<Plan, Record<string, number>> = {
aws_accounts: 3,
machines: 2,
history_retention_days: 90,
offline_grace_days: 7, // 7 days offline grace
},

// TEAM TIER ($99/mo)
Expand All @@ -267,6 +271,7 @@ export const PLAN_LIMITS: Record<Plan, Record<string, number>> = {
machines: 10,
team_members: 5,
history_retention_days: 365,
offline_grace_days: 14, // 14 days offline grace
},

// SOVEREIGN TIER ($2,500/mo)
Expand All @@ -289,9 +294,30 @@ export const PLAN_LIMITS: Record<Plan, Record<string, number>> = {
machines: -1,
team_members: -1,
history_retention_days: -1,
offline_grace_days: 365, // 365 days offline grace (air-gap support)
},
};

// =============================================================================
// Offline Grace Days by Plan
// =============================================================================

/**
* Offline grace period (days) by plan.
*
* When CLI cannot connect to the server:
* - COMMUNITY: Must be online (0 days)
* - PRO: 7 days grace
* - TEAM: 14 days grace
* - SOVEREIGN: 365 days grace (air-gap deployment support)
*/
export const OFFLINE_GRACE_DAYS: Record<Plan, number> = {
[Plan.COMMUNITY]: 0,
[Plan.PRO]: 7,
[Plan.TEAM]: 14,
[Plan.SOVEREIGN]: 365,
} as const;

// =============================================================================
// Feature Metadata
// =============================================================================
Expand Down Expand Up @@ -615,6 +641,34 @@ export function getFeatureFlags(plan: Plan): FeatureFlagsType {
};
}

// =============================================================================
// License Blob Helpers
// =============================================================================

/**
* Build SecureLicenseLimits for license blob signing.
* Maps plan limits to the structure expected by CLI.
*/
export function buildSecureLicenseLimits(plan: Plan): SecureLicenseLimits {
const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS[Plan.COMMUNITY];

return {
max_accounts: limits.aws_accounts ?? 1,
max_regions: -1, // Unlimited
max_resources_per_scan: limits.resources_per_scan ?? -1,
max_concurrent_scans: 1,
max_scans_per_day: limits.scan_count === -1 ? -1 : Math.ceil((limits.scan_count ?? 3) / 30),
offline_grace_days: limits.offline_grace_days ?? 0,
};
}

/**
* Get enabled features as string array for license blob.
*/
export function getEnabledFeatures(plan: Plan): string[] {
return (PLAN_FEATURES[plan] ?? []).map((f) => f.toString());
}

// =============================================================================
// Plan Comparison
// =============================================================================
Expand Down
84 changes: 73 additions & 11 deletions apps/api/src/handlers/activate-license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* License Activation Handler
* POST /v1/license/activate
*
* First-time activation of a license on a new machine
* First-time activation of a license on a new machine.
* Returns Ed25519 signed license_blob for offline validation.
*/

import type { Env } from '../types/env';
Expand All @@ -11,10 +12,10 @@ import type {
ActivateLicenseResponse,
} from '../types/api';
import { PLAN_FEATURES, MAX_MACHINE_CHANGES_PER_MONTH, type PlanType } from '../lib/constants';
import { Plan, buildSecureLicenseLimits, getEnabledFeatures } from '../features';
import { Errors, AppError } from '../lib/errors';
import {
validateLicenseKey,
validateMachineId,
normalizeLicenseKey,
normalizeMachineId,
truncateMachineId,
Expand All @@ -30,6 +31,8 @@ import {
logUsage,
getActiveMachines,
} from '../lib/db';
import { signLicenseBlob, type LicensePayload } from '../lib/ed25519';
import { detectFingerprintType, validateFingerprint } from '../lib/fingerprint';

/**
* Handle license activation request
Expand Down Expand Up @@ -57,16 +60,29 @@ export async function handleActivateLicense(
if (!body.license_key) {
throw Errors.invalidRequest('Missing license_key');
}
if (!body.machine_id) {
throw Errors.invalidRequest('Missing machine_id');

// Support both machine_fingerprint (new) and machine_id (legacy)
const rawFingerprint = body.machine_fingerprint || body.machine_id;
if (!rawFingerprint) {
throw Errors.invalidRequest('Missing machine_fingerprint');
}

// Validate fingerprint format (32 char lowercase hex)
if (!validateFingerprint(rawFingerprint.toLowerCase())) {
throw Errors.invalidRequest('Invalid fingerprint format (expected 32 char hex)');
}

// Validate formats
// Validate license key format
validateLicenseKey(body.license_key);
validateMachineId(body.machine_id);

const licenseKey = normalizeLicenseKey(body.license_key);
const machineId = normalizeMachineId(body.machine_id);
const machineId = normalizeMachineId(rawFingerprint);

// Detect fingerprint type
const fpMetadata = detectFingerprintType(
body.machine_info,
body.fingerprint_type
);

// Get license with all related data
const license = await getLicenseForValidation(db, licenseKey, machineId);
Expand All @@ -76,6 +92,7 @@ export async function handleActivateLicense(
}

const plan = license.plan as PlanType;
const planEnum = license.plan as Plan;
const features = PLAN_FEATURES[plan] ?? PLAN_FEATURES.community;

// Check license status
Expand All @@ -86,12 +103,37 @@ export async function handleActivateLicense(
throw Errors.licensePastDue();
}

// Helper to build and sign license blob
const buildLicenseBlob = async (): Promise<string> => {
const now = Math.floor(Date.now() / 1000);
const expSeconds = 24 * 60 * 60; // 24 hours

const payload: LicensePayload = {
license_key: licenseKey,
plan: license.plan,
status: license.status,
machine_fingerprint: machineId,
fingerprint_type: fpMetadata.type,
limits: buildSecureLicenseLimits(planEnum),
features: getEnabledFeatures(planEnum),
iat: now,
exp: now + expSeconds,
nbf: now - 60, // 1 minute clock skew tolerance
};

return signLicenseBlob(payload, env.ED25519_PRIVATE_KEY);
};

// Check if already activated on this machine
if (license.machine_is_active === 1) {
// Already activated - return success
// Already activated - return success with license_blob
const licenseBlob = await buildLicenseBlob();

const response: ActivateLicenseResponse = {
activated: true,
license_blob: licenseBlob,
plan: license.plan,
status: license.status,
machines_used: license.active_machines,
machines_limit: features.machines,
};
Expand All @@ -117,21 +159,41 @@ export async function handleActivateLicense(
throw Errors.machineChangeLimitExceeded(nextMonthStartISO());
}

// Register the machine
await registerMachine(db, license.license_id, machineId, body.machine_name);
// Register the machine with fingerprint metadata
await registerMachine(
db,
license.license_id,
machineId,
body.machine_name,
fpMetadata.type,
{
ci_provider: fpMetadata.ci_provider,
ci_repo: fpMetadata.ci_repo,
container_type: fpMetadata.container_type,
}
);
await recordMachineChange(db, license.license_id, machineId);

// Log usage
await logUsage(db, {
licenseId: license.license_id,
machineId,
action: 'activate',
metadata: { machine_name: body.machine_name },
metadata: {
machine_name: body.machine_name,
fingerprint_type: fpMetadata.type,
cli_version: body.cli_version,
},
});

// Generate license blob
const licenseBlob = await buildLicenseBlob();

const response: ActivateLicenseResponse = {
activated: true,
license_blob: licenseBlob,
plan: license.plan,
status: license.status,
machines_used: license.active_machines + 1,
machines_limit: features.machines,
};
Expand Down
43 changes: 39 additions & 4 deletions apps/api/src/handlers/validate-license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*
* This is called on every CLI run, so it must be fast (<50ms target)
*
* Returns Ed25519 signed license_blob for offline validation.
*
* Security features:
* - Zod schema validation for all inputs
* - Optional HMAC signature verification for machine IDs
Expand All @@ -19,7 +21,9 @@ import {
checkCliVersion,
type PlanType,
} from '../lib/constants';
import { Plan, getFeatureFlags, PLAN_LIMITS } from '../features';
import { Plan, getFeatureFlags, PLAN_LIMITS, buildSecureLicenseLimits, getEnabledFeatures } from '../features';
import { signLicenseBlob, type LicensePayload } from '../lib/ed25519';
import { detectFingerprintType } from '../lib/fingerprint';
import { Errors, AppError } from '../lib/errors';
import {
normalizeLicenseKey,
Expand Down Expand Up @@ -89,7 +93,17 @@ export async function handleValidateLicense(

const body = parseResult.data;
const licenseKey = normalizeLicenseKey(body.license_key);
const machineId = normalizeMachineId(body.machine_id);

// Support both machine_fingerprint (new) and machine_id (legacy)
const rawFingerprint = (body as { machine_fingerprint?: string }).machine_fingerprint || body.machine_id;
const machineId = normalizeMachineId(rawFingerprint);

// Detect fingerprint type from machine_info if available
const bodyWithMachineInfo = body as { machine_info?: { ci_provider?: string; ci_repo?: string; container_type?: string }; fingerprint_type?: 'machine' | 'ci' | 'container' };
const fpMetadata = detectFingerprintType(
bodyWithMachineInfo.machine_info,
bodyWithMachineInfo.fingerprint_type
);

// Verify machine signature if MACHINE_SIGNATURE_SECRET is configured
// This prevents machine ID forgery
Expand Down Expand Up @@ -215,16 +229,36 @@ export async function handleValidateLicense(
const featureFlags = getFeatureFlags(planEnum);
const newLimits = PLAN_LIMITS[planEnum] || PLAN_LIMITS[Plan.COMMUNITY];

// Generate Ed25519 signed license blob
const now = Math.floor(Date.now() / 1000);
const expSeconds = 24 * 60 * 60; // 24 hours

const licensePayload: LicensePayload = {
license_key: licenseKey,
plan: license.plan,
status: license.status,
machine_fingerprint: machineId,
fingerprint_type: fpMetadata.type,
limits: buildSecureLicenseLimits(planEnum),
features: getEnabledFeatures(planEnum),
iat: now,
exp: now + expSeconds,
nbf: now - 60, // 1 minute clock skew tolerance
};

const licenseBlob = await signLicenseBlob(licensePayload, env.ED25519_PRIVATE_KEY);

// Build success response
const response: ValidateLicenseResponse & {
_warning?: { message: string; upgrade_command: string };
_device_warning?: string;
_ci_warning?: string;
lease_token?: string; // Offline validation token
lease_token?: string; // Offline validation token (legacy)
} = {
valid: true,
plan: license.plan,
status: license.status,
license_blob: licenseBlob,
features: {
resources_per_scan: features.resources_per_scan,
scans_per_month: features.scans_per_month,
Expand All @@ -246,7 +280,7 @@ export async function handleValidateLicense(
cli_version: cliVersionCheck,
// NEW: Include feature flags for new features
new_features: featureFlags,
// NEW: Include extended limits
// NEW: Include extended limits with offline_grace_days
limits: {
audit_fix_count: newLimits.audit_fix_count,
snapshot_count: newLimits.snapshot_count,
Expand All @@ -256,6 +290,7 @@ export async function handleValidateLicense(
cost_count: newLimits.cost_count,
clone_preview_lines: newLimits.clone_preview_lines,
audit_visible_findings: newLimits.audit_visible_findings,
offline_grace_days: newLimits.offline_grace_days ?? 0,
},
};

Expand Down
Loading
Loading