diff --git a/apps/api/migrations/008_add_lifetime_support.sql b/apps/api/migrations/008_add_lifetime_support.sql index deb7807..b9773b5 100644 --- a/apps/api/migrations/008_add_lifetime_support.sql +++ b/apps/api/migrations/008_add_lifetime_support.sql @@ -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; @@ -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); diff --git a/apps/api/migrations/010_fingerprint_type.sql b/apps/api/migrations/010_fingerprint_type.sql new file mode 100644 index 0000000..e3e549a --- /dev/null +++ b/apps/api/migrations/010_fingerprint_type.sql @@ -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); diff --git a/apps/api/src/features.ts b/apps/api/src/features.ts index fc7ead7..b89a995 100644 --- a/apps/api/src/features.ts +++ b/apps/api/src/features.ts @@ -13,6 +13,8 @@ * - SOVEREIGN ($2,500): SSO, signed reports, air-gap, white-labeling */ +import type { SecureLicenseLimits } from './lib/ed25519'; + // ============================================================================= // Feature Enum // ============================================================================= @@ -224,6 +226,7 @@ export const PLAN_LIMITS: Record> = { aws_accounts: 1, machines: 1, history_retention_days: 7, + offline_grace_days: 0, // Must be online }, // PRO TIER ($29/mo) @@ -245,6 +248,7 @@ export const PLAN_LIMITS: Record> = { aws_accounts: 3, machines: 2, history_retention_days: 90, + offline_grace_days: 7, // 7 days offline grace }, // TEAM TIER ($99/mo) @@ -267,6 +271,7 @@ export const PLAN_LIMITS: Record> = { machines: 10, team_members: 5, history_retention_days: 365, + offline_grace_days: 14, // 14 days offline grace }, // SOVEREIGN TIER ($2,500/mo) @@ -289,9 +294,30 @@ export const PLAN_LIMITS: Record> = { 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.COMMUNITY]: 0, + [Plan.PRO]: 7, + [Plan.TEAM]: 14, + [Plan.SOVEREIGN]: 365, +} as const; + // ============================================================================= // Feature Metadata // ============================================================================= @@ -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 // ============================================================================= diff --git a/apps/api/src/handlers/activate-license.ts b/apps/api/src/handlers/activate-license.ts index b09086b..f90fc43 100644 --- a/apps/api/src/handlers/activate-license.ts +++ b/apps/api/src/handlers/activate-license.ts @@ -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'; @@ -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, @@ -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 @@ -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); @@ -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 @@ -86,12 +103,37 @@ export async function handleActivateLicense( throw Errors.licensePastDue(); } + // Helper to build and sign license blob + const buildLicenseBlob = async (): Promise => { + 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, }; @@ -117,8 +159,19 @@ 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 @@ -126,12 +179,21 @@ export async function handleActivateLicense( 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, }; diff --git a/apps/api/src/handlers/validate-license.ts b/apps/api/src/handlers/validate-license.ts index 543d45b..4552192 100644 --- a/apps/api/src/handlers/validate-license.ts +++ b/apps/api/src/handlers/validate-license.ts @@ -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 @@ -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, @@ -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 @@ -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, @@ -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, @@ -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, }, }; diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts index cbcc2d9..dd7605b 100644 --- a/apps/api/src/lib/db.ts +++ b/apps/api/src/lib/db.ts @@ -377,28 +377,51 @@ export async function updateLicensePlan( // Machine Queries & Mutations // ============================================================================ +/** Fingerprint type for machine registration */ +export type FingerprintType = 'machine' | 'ci' | 'container'; + +/** Fingerprint metadata for registration */ +export interface FingerprintMetadata { + ci_provider?: string; + ci_repo?: string; + container_type?: string; +} + /** - * Register a new machine for a license + * Register a new machine for a license with fingerprint metadata. + * + * @param db - Drizzle database client + * @param licenseId - License ID to bind machine to + * @param machineId - Machine fingerprint (32 char hex) + * @param machineName - Optional friendly name for the machine + * @param fingerprintType - Type of fingerprint: machine, ci, or container + * @param metadata - Additional metadata for CI/container environments */ export async function registerMachine( db: DrizzleDb, licenseId: string, machineId: string, - machineName?: string + machineName?: string, + fingerprintType: FingerprintType = 'machine', + metadata?: FingerprintMetadata ): Promise { const id = generateId(); const now = nowISO(); const normalizedMachineId = normalizeMachineId(machineId); - await db.insert(schema.licenseMachines).values({ - id, - licenseId, - machineId: normalizedMachineId, - machineName: machineName ?? null, - isActive: true, - firstSeenAt: now, - lastSeenAt: now, - }); + // Use raw SQL to support new columns that may not be in Drizzle schema yet + await db.run(sql` + INSERT INTO license_machines ( + id, license_id, machine_id, machine_name, + fingerprint_type, ci_provider, ci_repo, container_type, + is_active, first_seen_at, last_seen_at + ) VALUES ( + ${id}, ${licenseId}, ${normalizedMachineId}, ${machineName ?? null}, + ${fingerprintType}, ${metadata?.ci_provider ?? null}, + ${metadata?.ci_repo ?? null}, ${metadata?.container_type ?? null}, + 1, ${now}, ${now} + ) + `); } /** diff --git a/apps/api/src/lib/ed25519.ts b/apps/api/src/lib/ed25519.ts new file mode 100644 index 0000000..b17c2a8 --- /dev/null +++ b/apps/api/src/lib/ed25519.ts @@ -0,0 +1,299 @@ +/** + * Ed25519 License Blob Signing + * + * Blob Format: Base64URL(payload_json).Base64URL(signature) + * + * CLI verification workflow: + * 1. Split blob by '.' + * 2. Decode payload and signature from Base64URL + * 3. Verify: Ed25519.verify(payload_bytes, signature, public_key) + * 4. Parse payload JSON + * + * Security model: + * - Private key is stored in Cloudflare Workers secret (ED25519_PRIVATE_KEY) + * - Public key is embedded in CLI source code + * - Even if public key leaks, signatures cannot be forged + */ + +// ============================================================================ +// Types +// ============================================================================ + +export type FingerprintType = 'machine' | 'ci' | 'container'; + +/** + * License payload - signed and sent to CLI. + * Must match CLI's SecureLicenseData structure. + */ +export interface LicensePayload { + /** License key (full key for binding verification) */ + license_key: string; + + /** Plan: community, pro, team, sovereign */ + plan: string; + + /** Status: active, canceled, expired, past_due, revoked */ + status: string; + + /** Bound machine fingerprint */ + machine_fingerprint: string; + + /** Fingerprint type: machine, ci, container */ + fingerprint_type: FingerprintType; + + /** Plan limits */ + limits: SecureLicenseLimits; + + /** Enabled feature list */ + features: string[]; + + /** Issued at (Unix timestamp seconds) */ + iat: number; + + /** Expiration (Unix timestamp seconds) */ + exp: number; + + /** Not before (Unix timestamp seconds) - clock skew tolerance */ + nbf: number; +} + +/** + * Limits structure matching CLI SecureLicenseLimits + */ +export interface SecureLicenseLimits { + max_accounts: number; + max_regions: number; + max_resources_per_scan: number; + max_concurrent_scans: number; + max_scans_per_day: number; + offline_grace_days: number; +} + +// ============================================================================ +// Ed25519 Signing +// ============================================================================ + +/** + * Sign a license payload using Ed25519. + * + * @param payload - License payload to sign + * @param privateKeyBase64 - Base64 encoded Ed25519 private key (32 bytes seed or 64 bytes full) + * @returns Base64URL encoded blob: "payload.signature" + */ +export async function signLicenseBlob( + payload: LicensePayload, + privateKeyBase64: string +): Promise { + // 1. Serialize payload to JSON bytes + const payloadJson = JSON.stringify(payload); + const payloadBytes = new TextEncoder().encode(payloadJson); + const payloadBase64 = base64UrlEncode(payloadBytes); + + // 2. Import private key + const privateKeyBytes = base64Decode(privateKeyBase64); + + // Ed25519 keys can be 32-byte seed or 64-byte full key (seed + public) + // Web Crypto API expects the 32-byte seed + const keyData = + privateKeyBytes.length === 64 + ? privateKeyBytes.slice(0, 32) + : privateKeyBytes; + + if (keyData.length !== 32) { + throw new Error( + `Invalid Ed25519 key length: ${keyData.length} (expected 32 or 64 bytes)` + ); + } + + const privateKey = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'Ed25519' }, + false, + ['sign'] + ); + + // 3. Sign the payload bytes (not the base64 encoded version) + const signature = await crypto.subtle.sign('Ed25519', privateKey, payloadBytes); + + // 4. Encode signature and combine + const signatureBase64 = base64UrlEncode(new Uint8Array(signature)); + + return `${payloadBase64}.${signatureBase64}`; +} + +/** + * Verify a license blob (for testing and debug purposes). + * + * @param blob - The signed blob: "payload_base64.signature_base64" + * @param publicKeyBase64 - Base64 encoded Ed25519 public key (32 bytes) + * @returns Parsed payload if valid, null if invalid + */ +export async function verifyLicenseBlob( + blob: string, + publicKeyBase64: string +): Promise { + try { + const parts = blob.split('.'); + if (parts.length !== 2) { + return null; + } + + const [payloadBase64, signatureBase64] = parts; + if (!payloadBase64 || !signatureBase64) { + return null; + } + + const payloadBytes = base64UrlDecode(payloadBase64); + const signatureBytes = base64UrlDecode(signatureBase64); + const publicKeyBytes = base64Decode(publicKeyBase64); + + if (publicKeyBytes.length !== 32) { + return null; + } + + const publicKey = await crypto.subtle.importKey( + 'raw', + publicKeyBytes, + { name: 'Ed25519' }, + false, + ['verify'] + ); + + const isValid = await crypto.subtle.verify( + 'Ed25519', + publicKey, + signatureBytes, + payloadBytes + ); + + if (!isValid) { + return null; + } + + const payload: LicensePayload = JSON.parse( + new TextDecoder().decode(payloadBytes) + ); + + // Validate time bounds + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) { + return null; // Expired + } + if (payload.nbf > now) { + return null; // Not yet valid + } + + return payload; + } catch { + return null; + } +} + +// ============================================================================ +// Key Generation (run once to generate key pair) +// ============================================================================ + +/** + * Generate an Ed25519 key pair. + * + * Usage: + * 1. Run this function once (e.g., in wrangler dev or a script) + * 2. Store privateKey in Cloudflare Secret: ED25519_PRIVATE_KEY + * 3. Embed publicKey in CLI: replimap/licensing/crypto/keys.py + * + * @returns Object with privateKey, publicKey (Base64), and publicKeyHex + */ +export async function generateEd25519KeyPair(): Promise<{ + privateKey: string; + publicKey: string; + publicKeyHex: string; +}> { + const keyPair = (await crypto.subtle.generateKey( + { name: 'Ed25519' }, + true, // extractable + ['sign', 'verify'] + )) as CryptoKeyPair; + + const privateKeyBuffer = await crypto.subtle.exportKey('raw', keyPair.privateKey); + const publicKeyBuffer = await crypto.subtle.exportKey('raw', keyPair.publicKey); + + const privateKeyBytes = new Uint8Array(privateKeyBuffer as ArrayBuffer); + const publicKeyBytes = new Uint8Array(publicKeyBuffer as ArrayBuffer); + + return { + privateKey: base64Encode(privateKeyBytes), + publicKey: base64Encode(publicKeyBytes), + publicKeyHex: bytesToHex(publicKeyBytes), + }; +} + +// ============================================================================ +// Base64URL Helpers (RFC 4648) +// ============================================================================ + +/** + * Encode bytes to Base64URL (no padding). + */ +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** + * Decode Base64URL to bytes. + */ +function base64UrlDecode(str: string): Uint8Array { + // Restore standard Base64 characters + let padded = str.replace(/-/g, '+').replace(/_/g, '/'); + + // Add padding if needed + while (padded.length % 4) { + padded += '='; + } + + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Decode standard Base64 to bytes. + */ +function base64Decode(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Encode bytes to standard Base64. + */ +function base64Encode(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +/** + * Convert bytes to hex string. + */ +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/apps/api/src/lib/fingerprint.ts b/apps/api/src/lib/fingerprint.ts new file mode 100644 index 0000000..9edfc5a --- /dev/null +++ b/apps/api/src/lib/fingerprint.ts @@ -0,0 +1,162 @@ +/** + * Fingerprint Detection and Validation + * + * Supports three fingerprint types: + * - machine: Standard machine (MAC + hostname hash) + * - ci: CI/CD environment (provider:repo hash) + * - container: Container environment (volume UUID or workspace ID) + */ + +import type { FingerprintType } from './ed25519'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface MachineInfo { + platform?: string; + platform_version?: string; + platform_release?: string; + python_version?: string; + hostname?: string; + ci_provider?: string; + ci_repo?: string; + ci_run_id?: string; + container_type?: string; + workspace_id?: string; +} + +export interface FingerprintMetadata { + type: FingerprintType; + ci_provider?: string; + ci_repo?: string; + container_type?: string; +} + +// ============================================================================ +// Detection +// ============================================================================ + +/** + * Detect fingerprint type from machine info and explicit type. + * + * Priority: + * 1. Explicit type (if provided) + * 2. CI detection (if ci_provider present) + * 3. Container detection (if container_type present) + * 4. Default to machine + */ +export function detectFingerprintType( + machineInfo?: MachineInfo, + explicitType?: FingerprintType +): FingerprintMetadata { + // 1. If explicitly specified, use that + if (explicitType) { + return buildMetadata(explicitType, machineInfo); + } + + // 2. Detect CI environment + if (machineInfo?.ci_provider) { + return { + type: 'ci', + ci_provider: machineInfo.ci_provider, + ci_repo: machineInfo.ci_repo, + }; + } + + // 3. Detect container environment + if (machineInfo?.container_type) { + return { + type: 'container', + container_type: machineInfo.container_type, + }; + } + + // 4. Default to machine type + return { type: 'machine' }; +} + +function buildMetadata( + type: FingerprintType, + machineInfo?: MachineInfo +): FingerprintMetadata { + switch (type) { + case 'ci': + return { + type: 'ci', + ci_provider: machineInfo?.ci_provider, + ci_repo: machineInfo?.ci_repo, + }; + case 'container': + return { + type: 'container', + container_type: machineInfo?.container_type, + }; + default: + return { type: 'machine' }; + } +} + +// ============================================================================ +// Validation +// ============================================================================ + +/** + * Validate fingerprint format (32 char lowercase hex). + */ +export function validateFingerprint(fingerprint: string): boolean { + return /^[a-f0-9]{32}$/.test(fingerprint); +} + +/** + * Validate fingerprint type. + */ +export function isValidFingerprintType(type: string): type is FingerprintType { + return ['machine', 'ci', 'container'].includes(type); +} + +/** + * Generate CI stable identifier. + */ +export function getCIStableIdentifier(provider: string, repo: string): string { + return `${provider}:${repo}`; +} + +// ============================================================================ +// Display Helpers +// ============================================================================ + +/** + * Get fingerprint type display label. + */ +export function getFingerprintTypeLabel(type: FingerprintType): string { + const labels: Record = { + machine: 'Local Machine', + ci: 'CI/CD Pipeline', + container: 'Container/Cloud IDE', + }; + return labels[type] ?? 'Unknown'; +} + +/** + * Get fingerprint type icon. + */ +export function getFingerprintTypeIcon(type: FingerprintType): string { + const icons: Record = { + machine: '๐Ÿ’ป', + ci: '๐Ÿ”„', + container: '๐Ÿ“ฆ', + }; + return icons[type] ?? 'โ“'; +} + +/** + * Get short display string for a fingerprint. + * Shows first 8 and last 4 characters. + */ +export function truncateFingerprint(fingerprint: string): string { + if (fingerprint.length < 12) { + return fingerprint; + } + return `${fingerprint.slice(0, 8)}...${fingerprint.slice(-4)}`; +} diff --git a/apps/api/src/types/api.ts b/apps/api/src/types/api.ts index 9e948fa..d5993cc 100644 --- a/apps/api/src/types/api.ts +++ b/apps/api/src/types/api.ts @@ -2,20 +2,59 @@ * API Request/Response types for RepliMap Backend */ +import type { FingerprintType } from '../lib/ed25519'; + // ============================================================================ // Request Types // ============================================================================ +/** + * Machine info sent by CLI for fingerprint detection and analytics. + */ +export interface MachineInfoRequest { + platform?: string; + platform_version?: string; + platform_release?: string; + python_version?: string; + hostname?: string; + // CI-related + ci_provider?: string; + ci_repo?: string; + ci_run_id?: string; + // Container-related + container_type?: string; + workspace_id?: string; +} + export interface ValidateLicenseRequest { license_key: string; - machine_id: string; + /** Machine fingerprint (32 char hex). Also accepts legacy 'machine_id' field. */ + machine_fingerprint?: string; + /** @deprecated Use machine_fingerprint instead */ + machine_id?: string; + /** Fingerprint type (auto-detected if not provided) */ + fingerprint_type?: FingerprintType; + /** Machine info for detection and analytics */ + machine_info?: MachineInfoRequest; cli_version?: string; + // Legacy security fields + machine_signature?: string; + timestamp?: number; + is_ci?: boolean; } export interface ActivateLicenseRequest { license_key: string; - machine_id: string; + /** Machine fingerprint (32 char hex). Also accepts legacy 'machine_id' field. */ + machine_fingerprint?: string; + /** @deprecated Use machine_fingerprint instead */ + machine_id?: string; + /** Fingerprint type (auto-detected if not provided) */ + fingerprint_type?: FingerprintType; + /** Machine info for detection and analytics */ + machine_info?: MachineInfoRequest; machine_name?: string; + cli_version?: string; } export interface DeactivateLicenseRequest { @@ -79,12 +118,16 @@ export interface ExtendedLimits { cost_count: number; clone_preview_lines: number; audit_visible_findings: number; + /** Offline grace period in days */ + offline_grace_days: number; } export interface ValidateLicenseResponse { valid: true; plan: string; status: string; + /** Ed25519 signed license blob for offline validation */ + license_blob: string; features: PlanFeatures; usage: UsageInfo; expires_at: string | null; @@ -98,7 +141,10 @@ export interface ValidateLicenseResponse { export interface ActivateLicenseResponse { activated: true; + /** Ed25519 signed license blob for offline validation */ + license_blob: string; plan: string; + status: string; machines_used: number; machines_limit: number; } diff --git a/apps/api/src/types/env.ts b/apps/api/src/types/env.ts index 43b72e2..cf107d0 100644 --- a/apps/api/src/types/env.ts +++ b/apps/api/src/types/env.ts @@ -27,6 +27,11 @@ export interface Env { // If set, includes a signed JWT in validate response that CLI can cache LEASE_TOKEN_SECRET?: string; + // Ed25519 License Signing (required for license_blob generation) + // Generate with: npx tsx scripts/generate-keys.ts + // Store via: wrangler secret put ED25519_PRIVATE_KEY + ED25519_PRIVATE_KEY: string; + // Environment variables ENVIRONMENT: 'development' | 'production'; CORS_ORIGIN: string; diff --git a/apps/web/src/app/dashboard/license/page.tsx b/apps/web/src/app/dashboard/license/page.tsx new file mode 100644 index 0000000..082fab8 --- /dev/null +++ b/apps/web/src/app/dashboard/license/page.tsx @@ -0,0 +1,123 @@ +import { currentUser } from '@clerk/nextjs/server'; +import { redirect } from 'next/navigation'; +import Link from 'next/link'; +import { getLicenseDetails, getUserLicenseKey, getMachinesLimit } from '@/lib/api'; +import { LicenseCard } from '@/components/license-card'; +import { DeviceList } from '@/components/device-list'; +import { GracePeriodInfo } from '@/components/grace-period-info'; + +export const dynamic = 'force-dynamic'; + +export default async function LicensePage() { + const user = await currentUser(); + + if (!user) { + redirect('/sign-in'); + } + + // Get user's license key + const licenseKey = await getUserLicenseKey(user.id); + + if (!licenseKey) { + return ( +
+
+
+

License

+ + โ† Back to Dashboard + +
+ +
+
+ ); + } + + // Get license details + let license; + try { + license = await getLicenseDetails(licenseKey); + } catch (error) { + return ( +
+
+
+

License

+ + โ† Back to Dashboard + +
+ +
+
+ ); + } + + return ( +
+
+
+

License

+ + โ† Back to Dashboard + +
+ +
+ {/* License overview card */} + + + {/* Offline grace period info */} + + + {/* Activated devices list */} + +
+
+
+ ); +} + +function NoLicenseState() { + return ( +
+
๐Ÿ”‘
+

No License Found

+

+ Install the CLI and activate your license to get started. +

+ + pip install replimap && replimap auth login + +
+ ); +} + +function ErrorState({ error }: { error: unknown }) { + return ( +
+
โš ๏ธ
+

Failed to Load License

+

+ {error instanceof Error ? error.message : 'An unexpected error occurred'} +

+
+ ); +} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 2f7f9df..6bfb57f 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,18 +1,35 @@ -import { currentUser } from "@clerk/nextjs/server"; -import { redirect } from "next/navigation"; +import { currentUser } from '@clerk/nextjs/server'; +import { redirect } from 'next/navigation'; +import Link from 'next/link'; +import { getLicenseDetails, getUserLicenseKey, getMachinesLimit } from '@/lib/api'; +import { LicenseSummaryCard } from '@/components/license-summary-card'; +import { DeviceSummaryCard } from '@/components/device-summary-card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; // Force dynamic rendering - this page requires auth -export const dynamic = "force-dynamic"; +export const dynamic = 'force-dynamic'; export default async function DashboardPage() { const user = await currentUser(); if (!user) { - redirect("/sign-in"); + redirect('/sign-in'); } const displayName = - user.firstName || user.emailAddresses[0]?.emailAddress || "User"; + user.firstName || user.emailAddresses[0]?.emailAddress || 'User'; + + // Try to get license + const licenseKey = await getUserLicenseKey(user.id); + let license = null; + + if (licenseKey) { + try { + license = await getLicenseDetails(licenseKey); + } catch { + // Ignore error, show default state + } + } return (
@@ -23,30 +40,74 @@ export default async function DashboardPage() {

Your RepliMap Dashboard

-
-

License

-

Community

-

- Unlimited scans ยท JSON export -

-
+ {/* License overview */} + -
-

Usage

-

0 Scans

-

- 0 resources analyzed -

+ {/* Device overview */} + + + {/* Quick start */} + +
+ + {/* Quick links if license exists */} + {license && ( +
+

Quick Links

+
+ + View License Details โ†’ + + + Documentation โ†’ + +
+ )} +
+
+ ); +} -
-

Quick Start

+function QuickStartCard({ hasLicense }: { hasLicense: boolean }) { + return ( + + + Quick Start + + + {hasLicense ? ( + <> +

+ Run a scan to visualize your AWS infrastructure: +

+ replimap scan + + + ) : ( + <> +

+ Install the CLI to get started: +

+ pip install replimap -
- - - +

+ Then authenticate: +

+ + replimap auth login + + + )} + + ); } diff --git a/apps/web/src/components/device-list.tsx b/apps/web/src/components/device-list.tsx new file mode 100644 index 0000000..dfb1c9d --- /dev/null +++ b/apps/web/src/components/device-list.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from '@/components/ui/dialog'; +import { + FingerprintBadge, + getDeviceLabel, + truncateFingerprint, +} from '@/components/fingerprint-badge'; +import { deactivateDevice } from '@/lib/api'; +import type { Fingerprint } from '@/types/license'; + +interface DeviceListProps { + devices: Fingerprint[]; + licenseKey: string; + machinesLimit: number; +} + +export function DeviceList({ + devices, + licenseKey, + machinesLimit, +}: DeviceListProps) { + const router = useRouter(); + const [deactivating, setDeactivating] = useState(null); + const [error, setError] = useState(null); + + const handleDeactivate = async (fingerprint: string) => { + setDeactivating(fingerprint); + setError(null); + + try { + await deactivateDevice({ + license_key: licenseKey, + machine_fingerprint: fingerprint, + }); + router.refresh(); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to deactivate device' + ); + } finally { + setDeactivating(null); + } + }; + + const limitText = + machinesLimit === -1 + ? `${devices.length} devices` + : `${devices.length} / ${machinesLimit} devices`; + + return ( + + +
+ Active Devices + {limitText} +
+
+ + {error && ( +
+ {error} +
+ )} + + {devices.length === 0 ? ( +
+
๐Ÿ“ฑ
+

No devices activated yet

+

+ Run{' '} + replimap auth login to + activate a device +

+
+ ) : ( +
+ {devices.map((device) => ( + handleDeactivate(device.fingerprint)} + /> + ))} +
+ )} +
+
+ ); +} + +interface DeviceRowProps { + device: Fingerprint; + isDeactivating: boolean; + onDeactivate: () => void; +} + +function DeviceRow({ device, isDeactivating, onDeactivate }: DeviceRowProps) { + const [open, setOpen] = useState(false); + + const handleConfirm = () => { + setOpen(false); + onDeactivate(); + }; + + return ( +
+
+ + +
+
+ {getDeviceLabel( + device.type, + device.ci_provider, + device.container_type + )} +
+ + {device.type === 'ci' && device.ci_repo && ( +
+ {device.ci_provider}: {device.ci_repo} +
+ )} + +
+ {truncateFingerprint(device.fingerprint)} +
+
+
+ +
+ + {formatTimeAgo(new Date(device.last_seen))} + + + + + + + + + Remove Device? + + This will deactivate the license on this device. The device will + need to re-authenticate to use RepliMap again. + + + + + + + + + + +
+
+ ); +} + +function formatTimeAgo(date: Date): string { + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return days === 1 ? '1 day ago' : `${days} days ago`; + } + if (hours > 0) { + return hours === 1 ? '1 hour ago' : `${hours} hours ago`; + } + if (minutes > 0) { + return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`; + } + return 'Just now'; +} diff --git a/apps/web/src/components/device-summary-card.tsx b/apps/web/src/components/device-summary-card.tsx new file mode 100644 index 0000000..3b11172 --- /dev/null +++ b/apps/web/src/components/device-summary-card.tsx @@ -0,0 +1,61 @@ +import Link from 'next/link'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import type { Fingerprint } from '@/types/license'; + +interface DeviceSummaryCardProps { + devices: Fingerprint[]; + limit: number; +} + +export function DeviceSummaryCard({ devices, limit }: DeviceSummaryCardProps) { + const limitText = limit === -1 ? 'Unlimited' : `${devices.length}/${limit}`; + + // Count by type + const counts = { + machine: devices.filter((d) => d.type === 'machine').length, + ci: devices.filter((d) => d.type === 'ci').length, + container: devices.filter((d) => d.type === 'container').length, + }; + + return ( + + + + Active Devices + + +

{limitText}

+ + {devices.length > 0 ? ( +
+ {counts.machine > 0 && ( + + ๐Ÿ’ป {counts.machine} + + )} + {counts.ci > 0 && ( + + ๐Ÿ”„ {counts.ci} + + )} + {counts.container > 0 && ( + + ๐Ÿ“ฆ {counts.container} + + )} +
+ ) : ( +

+ No devices activated yet +

+ )} +
+
+ + ); +} diff --git a/apps/web/src/components/fingerprint-badge.tsx b/apps/web/src/components/fingerprint-badge.tsx new file mode 100644 index 0000000..26f5b53 --- /dev/null +++ b/apps/web/src/components/fingerprint-badge.tsx @@ -0,0 +1,90 @@ +import { cn } from '@/lib/utils'; +import type { FingerprintType } from '@/types/license'; + +interface FingerprintBadgeProps { + type: FingerprintType; + size?: 'sm' | 'md' | 'lg'; + showLabel?: boolean; +} + +const config: Record< + FingerprintType, + { icon: string; label: string; bg: string } +> = { + machine: { + icon: '๐Ÿ’ป', + label: 'Local Machine', + bg: 'bg-blue-500/10', + }, + ci: { + icon: '๐Ÿ”„', + label: 'CI/CD', + bg: 'bg-green-500/10', + }, + container: { + icon: '๐Ÿ“ฆ', + label: 'Container', + bg: 'bg-purple-500/10', + }, +}; + +export function FingerprintBadge({ + type, + size = 'md', + showLabel = false, +}: FingerprintBadgeProps) { + const { icon, label, bg } = config[type] ?? config.machine; + + const sizeClasses = { + sm: 'w-8 h-8 text-lg', + md: 'w-10 h-10 text-xl', + lg: 'w-12 h-12 text-2xl', + }; + + return ( +
+
+ {icon} +
+ {showLabel && {label}} +
+ ); +} + +/** + * Get device label based on fingerprint type and metadata + */ +export function getDeviceLabel( + type: FingerprintType, + ciProvider?: string | null, + containerType?: string | null +): string { + switch (type) { + case 'machine': + return 'Local Machine'; + case 'ci': + return ciProvider + ? `${ciProvider.charAt(0).toUpperCase() + ciProvider.slice(1)} CI` + : 'CI/CD Pipeline'; + case 'container': + return containerType || 'Container'; + default: + return 'Unknown Device'; + } +} + +/** + * Truncate fingerprint for display + */ +export function truncateFingerprint(fingerprint: string): string { + if (fingerprint.length < 12) { + return fingerprint; + } + return `${fingerprint.slice(0, 8)}...${fingerprint.slice(-4)}`; +} diff --git a/apps/web/src/components/grace-period-info.tsx b/apps/web/src/components/grace-period-info.tsx new file mode 100644 index 0000000..61af49f --- /dev/null +++ b/apps/web/src/components/grace-period-info.tsx @@ -0,0 +1,92 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +interface GracePeriodInfoProps { + graceDays: number; + plan: string; +} + +export function GracePeriodInfo({ graceDays, plan }: GracePeriodInfoProps) { + if (graceDays === 0) { + return ( + + +
+ Offline Mode + + Online Required + +
+
+ +

+ Your {plan} plan + requires an internet connection to validate your license. Consider + upgrading for offline grace period. +

+
+ +
+
+
+ ); + } + + return ( + + +
+ Offline Mode + + {graceDays} Days Grace + +
+
+ +

+ Your license can work offline for up to{' '} + {graceDays} days without + contacting the server. After that, you'll need to reconnect to + continue using RepliMap. +

+ + {graceDays >= 365 && ( +
+
+ ๐Ÿ›๏ธ + + Sovereign Grade + +
+

+ Suitable for air-gapped and highly regulated environments. +

+
+ )} +
+
+ ); +} + +function UpgradeHint({ currentPlan }: { currentPlan: string }) { + const upgrades: Record = { + community: { plan: 'Pro', days: 7 }, + pro: { plan: 'Team', days: 14 }, + team: { plan: 'Sovereign', days: 365 }, + }; + + const hint = upgrades[currentPlan]; + + if (!hint) return null; + + return ( +

+ ๐Ÿ’ก Upgrade to {hint.plan} for{' '} + {hint.days} days offline grace. +

+ ); +} diff --git a/apps/web/src/components/license-card.tsx b/apps/web/src/components/license-card.tsx new file mode 100644 index 0000000..a71ef44 --- /dev/null +++ b/apps/web/src/components/license-card.tsx @@ -0,0 +1,139 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import type { LicenseDetails } from '@/types/license'; + +interface LicenseCardProps { + license: LicenseDetails; +} + +export function LicenseCard({ license }: LicenseCardProps) { + return ( + + +
+ License Details + +
+
+ +
+
+
Plan
+
+ {license.plan} +
+
+ +
+
License Key
+
{license.license_key}
+
+ +
+
Expires
+
+ {license.expires_at ? ( + <> + + {new Date(license.expires_at).toLocaleDateString()} + + + {formatTimeUntil(new Date(license.expires_at))} + + + ) : ( + Never + )} +
+
+ +
+
Active Devices
+
+ {license.fingerprints.length} +
+
+
+
+
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const config: Record< + string, + { bg: string; text: string; label: string } + > = { + active: { + bg: 'bg-emerald-500/10', + text: 'text-emerald-500', + label: 'Active', + }, + canceled: { + bg: 'bg-amber-500/10', + text: 'text-amber-500', + label: 'Canceled', + }, + past_due: { + bg: 'bg-amber-500/10', + text: 'text-amber-500', + label: 'Past Due', + }, + expired: { + bg: 'bg-red-500/10', + text: 'text-red-500', + label: 'Expired', + }, + revoked: { + bg: 'bg-red-500/10', + text: 'text-red-500', + label: 'Revoked', + }, + }; + + const { bg, text, label } = config[status] ?? { + bg: 'bg-gray-500/10', + text: 'text-gray-500', + label: status, + }; + + return ( + + {label} + + ); +} + +function formatTimeUntil(date: Date): string { + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 0) { + return 'Expired'; + } + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) { + return 'Today'; + } + if (days === 1) { + return 'Tomorrow'; + } + if (days < 30) { + return `in ${days} days`; + } + if (days < 365) { + const months = Math.floor(days / 30); + return `in ${months} month${months > 1 ? 's' : ''}`; + } + + const years = Math.floor(days / 365); + return `in ${years} year${years > 1 ? 's' : ''}`; +} diff --git a/apps/web/src/components/license-summary-card.tsx b/apps/web/src/components/license-summary-card.tsx new file mode 100644 index 0000000..d9b03dd --- /dev/null +++ b/apps/web/src/components/license-summary-card.tsx @@ -0,0 +1,85 @@ +import Link from 'next/link'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import type { LicenseDetails } from '@/types/license'; + +interface LicenseSummaryCardProps { + license: LicenseDetails | null; +} + +export function LicenseSummaryCard({ license }: LicenseSummaryCardProps) { + if (!license) { + return ( + + + License + + +

No License

+

+ Run{' '} + replimap auth login to + activate +

+
+
+ ); + } + + const planColors: Record = { + community: 'text-gray-400', + pro: 'text-emerald-400', + team: 'text-purple-400', + sovereign: 'text-amber-400', + }; + + const statusConfig: Record = { + active: { color: 'bg-emerald-500', label: 'Active' }, + canceled: { color: 'bg-amber-500', label: 'Canceled' }, + past_due: { color: 'bg-amber-500', label: 'Past Due' }, + expired: { color: 'bg-red-500', label: 'Expired' }, + revoked: { color: 'bg-red-500', label: 'Revoked' }, + }; + + const status = statusConfig[license.status] ?? { + color: 'bg-gray-500', + label: license.status, + }; + + return ( + + + +
+ License +
+
+
+
+ +

+ {license.plan} +

+

+ {license.offline_grace_days > 0 + ? `${license.offline_grace_days} days offline grace` + : 'Requires internet connection'} +

+
+
+ + ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..a8a6038 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,175 @@ +/** + * API Client for License Management + * + * Communicates with the RepliMap backend API for license operations. + */ + +import type { + LicenseDetails, + LicenseUsage, + DeactivateRequest, + DeactivateResponse, + ApiErrorResponse, +} from '@/types/license'; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL || 'https://api.replimap.com'; + +/** + * API Error class with structured error information + */ +export class ApiError extends Error { + constructor( + public code: string, + message: string, + public status: number, + public details?: Record + ) { + super(message); + this.name = 'ApiError'; + } +} + +/** + * Generic request function with error handling + */ +async function request( + endpoint: string, + options: RequestInit & { licenseKey?: string } = {} +): Promise { + const { licenseKey, ...fetchOptions } = options; + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...fetchOptions.headers, + }; + + if (licenseKey) { + (headers as Record)['X-License-Key'] = licenseKey; + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...fetchOptions, + headers, + }); + + let data: T | ApiErrorResponse; + try { + data = await response.json(); + } catch { + throw new ApiError( + 'PARSE_ERROR', + 'Failed to parse response', + response.status + ); + } + + if (!response.ok) { + const errorData = data as ApiErrorResponse; + throw new ApiError( + errorData.error || 'UNKNOWN_ERROR', + errorData.message || 'An error occurred', + response.status, + errorData.details + ); + } + + return data as T; +} + +/** + * Get license details for the authenticated user + * + * @param licenseKey - The user's license key + * @returns License details including plan, status, and devices + */ +export async function getLicenseDetails( + licenseKey: string +): Promise { + return request('/v1/me/license', { + method: 'GET', + licenseKey, + cache: 'no-store', + }); +} + +/** + * Get license usage statistics + * + * @param licenseKey - The user's license key + * @returns Usage statistics + */ +export async function getLicenseUsage( + licenseKey: string +): Promise { + return request('/v1/me/usage', { + method: 'GET', + licenseKey, + cache: 'no-store', + }); +} + +/** + * Deactivate a device from the license + * + * @param data - License key and fingerprint to deactivate + * @returns Deactivation result + */ +export async function deactivateDevice( + data: DeactivateRequest +): Promise { + return request('/v1/license/deactivate', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** + * Get the user's license key from their profile + * + * This would typically fetch from a user-license mapping endpoint + * or from Clerk user metadata. + * + * @param userId - Clerk user ID + * @returns License key or null if not found + */ +export async function getUserLicenseKey( + userId: string +): Promise { + try { + const response = await request<{ license_key: string | null }>( + `/v1/users/${userId}/license-key`, + { method: 'GET' } + ); + return response.license_key; + } catch { + // User may not have a license yet + return null; + } +} + +/** + * Machine limit by plan + */ +export function getMachinesLimit(plan: string): number { + const limits: Record = { + community: 1, + pro: 2, + team: 10, + sovereign: -1, // Unlimited + }; + return limits[plan] ?? 1; +} + +/** + * Offline grace days by plan + */ +export function getOfflineGraceDays(plan: string): number { + const days: Record = { + community: 0, + pro: 7, + team: 14, + sovereign: 365, + }; + return days[plan] ?? 0; +} diff --git a/apps/web/src/lib/pricing.ts b/apps/web/src/lib/pricing.ts index 4a76c8b..dfc4bd9 100644 --- a/apps/web/src/lib/pricing.ts +++ b/apps/web/src/lib/pricing.ts @@ -41,6 +41,8 @@ export interface Plan { highlighted: boolean; hasLifetime: boolean; badge: string | null; + /** Offline grace period in days */ + offlineGraceDays: number; } export const PLANS: Record = { @@ -49,6 +51,7 @@ export const PLANS: Record = { tagline: "Full visibility, export when ready", price: { monthly: 0, annual: 0, lifetime: null }, description: "See your entire AWS infrastructure. Upgrade when you're ready to take it home.", + offlineGraceDays: 0, features: [ { text: "Unlimited scans", included: true }, { text: "Unlimited resources per scan", included: true }, @@ -58,6 +61,7 @@ export const PLANS: Record = { { text: "Compliance issues (view)", included: true }, { text: "Security score", included: true }, { text: "JSON export (with upgrade prompts)", included: true }, + { text: "Requires internet connection", included: true, badge: "Online only" }, { text: "Terraform export", included: false }, { text: "CSV export", included: false }, { text: "API access", included: false }, @@ -72,9 +76,11 @@ export const PLANS: Record = { tagline: "Export your infrastructure as code", price: { monthly: 29, annual: 290, lifetime: 199 }, description: "Take your Terraform code home. Perfect for individual DevOps engineers and SREs.", + offlineGraceDays: 7, features: [ { text: "Everything in Community", included: true }, { text: "3 AWS accounts", included: true }, + { text: "7 days offline grace period", included: true }, { text: "Terraform code export", included: true }, { text: "CSV export", included: true }, { text: "HTML/Markdown export", included: true }, @@ -97,10 +103,12 @@ export const PLANS: Record = { tagline: "Continuous compliance for your organization", price: { monthly: 99, annual: 990, lifetime: 499 }, description: "Drift alerts, compliance reports, and CI/CD integration for growing teams.", + offlineGraceDays: 14, features: [ { text: "Everything in Pro", included: true }, { text: "10 AWS accounts", included: true }, { text: "5 team members", included: true }, + { text: "14 days offline grace period", included: true }, { text: "Drift detection", included: true }, { text: "Drift alerts (Slack/Teams/Webhook)", included: true }, { text: "CI/CD integration (--fail-on)", included: true }, @@ -122,10 +130,12 @@ export const PLANS: Record = { tagline: "Data sovereignty for regulated industries", price: { monthly: 2500, annual: 25000, lifetime: null }, description: "When your regulator asks 'Where does the data go?', the answer is: Nowhere.", + offlineGraceDays: 365, features: [ { text: "Everything in Team", included: true }, { text: "Unlimited AWS accounts", included: true }, { text: "Unlimited team members", included: true }, + { text: "365 days offline grace", included: true, badge: "Air-gapped" }, { text: "SSO (SAML/OIDC)", included: true }, { text: "APRA CPS 234 mapping", included: true }, { text: "DORA compliance", included: true }, @@ -276,3 +286,25 @@ export function getUpgradePath(currentPlan: PlanName): PlanName | null { }; return upgradePaths[currentPlan]; } + +// ============================================================================= +// Offline Grace Days +// ============================================================================= + +/** + * Get offline grace days for a plan + */ +export function getOfflineGraceDays(plan: string): number { + const normalized = normalizePlanName(plan); + return PLANS[normalized]?.offlineGraceDays ?? 0; +} + +/** + * Get offline grace label for display + */ +export function getOfflineGraceLabel(plan: string): string { + const days = getOfflineGraceDays(plan); + if (days === 0) return "Requires internet"; + if (days >= 365) return `${days} days (air-gap support)`; + return `${days} days grace`; +} diff --git a/apps/web/src/types/license.ts b/apps/web/src/types/license.ts new file mode 100644 index 0000000..1db35b6 --- /dev/null +++ b/apps/web/src/types/license.ts @@ -0,0 +1,91 @@ +/** + * License Types for CLI Phase 3 Dashboard + * + * Fingerprint types: + * - machine: Local development machine + * - ci: CI/CD pipeline (GitHub Actions, GitLab CI, etc.) + * - container: Container/Cloud IDE (Docker, Codespaces, etc.) + */ + +export type FingerprintType = 'machine' | 'ci' | 'container'; + +export type LicenseStatus = + | 'active' + | 'canceled' + | 'expired' + | 'past_due' + | 'revoked'; + +export type PlanName = 'community' | 'pro' | 'team' | 'sovereign'; + +/** + * Device/machine fingerprint with metadata + */ +export interface Fingerprint { + /** 32-char hex fingerprint */ + fingerprint: string; + /** Type of fingerprint */ + type: FingerprintType; + /** Last seen timestamp (ISO 8601) */ + last_seen: string; + /** CI provider name (for ci type) */ + ci_provider?: string | null; + /** CI repository (for ci type) */ + ci_repo?: string | null; + /** Container type (for container type) */ + container_type?: string | null; +} + +/** + * License details returned by /v1/me/license + */ +export interface LicenseDetails { + /** Truncated license key (RM-XXXX-...) */ + license_key: string; + /** Plan name */ + plan: PlanName; + /** License status */ + status: LicenseStatus; + /** Expiration date (ISO 8601) or null for lifetime */ + expires_at: string | null; + /** Offline grace period in days */ + offline_grace_days: number; + /** List of activated devices */ + fingerprints: Fingerprint[]; +} + +/** + * Usage statistics + */ +export interface LicenseUsage { + scans_this_month: number; + machines_active: number; + machines_limit: number; + aws_accounts_active: number; + aws_accounts_limit: number; +} + +/** + * Deactivate device request + */ +export interface DeactivateRequest { + license_key: string; + machine_fingerprint: string; +} + +/** + * Deactivate device response + */ +export interface DeactivateResponse { + deactivated: boolean; + machines_remaining: number; +} + +/** + * API error response + */ +export interface ApiErrorResponse { + error: string; + message: string; + details?: Record; +}