diff --git a/services/platform-service/src/lib/auto-register.test.ts b/services/platform-service/src/lib/auto-register.test.ts new file mode 100644 index 00000000..c9e37b70 --- /dev/null +++ b/services/platform-service/src/lib/auto-register.test.ts @@ -0,0 +1,109 @@ +/** + * Auto-Registration tests — zero-touch product provisioning. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { autoRegisterProduct, resetRateLimiter } from './auto-register.js'; +import { loadProductCache, isValidProduct } from '../modules/products/cache.js'; +import * as productRepo from '../modules/products/repository.js'; + +// Seed a known product so cache isn't empty +async function seedProduct(id: string): Promise { + await productRepo.create({ + id, + productId: id, + displayName: id.charAt(0).toUpperCase() + id.slice(1), + licensePrefix: id.slice(0, 4).toUpperCase(), + packageName: '', + defaultPlan: 'free', + trialDays: 14, + deviceLimits: { free: 1, pro: 3, enterprise: 10 }, + websiteUrl: '', + status: 'active', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + await loadProductCache(); +} + +describe('autoRegisterProduct', () => { + beforeEach(async () => { + resetRateLimiter(); + // Ensure cache is loaded (even if empty) + await loadProductCache(); + }); + + it('should auto-register an unknown productId', async () => { + const result = await autoRegisterProduct('new-test-product', 'user-123'); + + expect(result.registered).toBe(true); + expect(result.product).toBeDefined(); + expect(result.product!.productId).toBe('new-test-product'); + expect(result.product!.status).toBe('active'); + expect(result.product!.displayName).toBe('New Test Product'); + expect(result.product!.defaultPlan).toBe('free'); + expect(result.product!.trialDays).toBe(14); + + // Cache should now include the new product + expect(isValidProduct('new-test-product')).toBe(true); + }); + + it('should return already exists for a known product', async () => { + await seedProduct('existing-prod'); + + const result = await autoRegisterProduct('existing-prod', 'user-456'); + + expect(result.registered).toBe(false); + expect(result.reason).toBe('Already exists'); + expect(result.product).toBeDefined(); + }); + + it('should reject invalid productId format — uppercase', async () => { + const result = await autoRegisterProduct('BadProduct', 'user-123'); + + expect(result.registered).toBe(false); + expect(result.reason).toBe('Invalid productId format'); + }); + + it('should reject invalid productId format — special characters', async () => { + const result = await autoRegisterProduct('bad.product!', 'user-123'); + + expect(result.registered).toBe(false); + expect(result.reason).toBe('Invalid productId format'); + }); + + it('should reject empty productId', async () => { + const result = await autoRegisterProduct('', 'user-123'); + + expect(result.registered).toBe(false); + expect(result.reason).toBe('Invalid productId format'); + }); + + it('should reject productId exceeding max length', async () => { + const longId = 'a'.repeat(65); + const result = await autoRegisterProduct(longId, 'user-123'); + + expect(result.registered).toBe(false); + expect(result.reason).toBe('Invalid productId format'); + }); + + it('should rate limit after max registrations', async () => { + // Register 10 products (the max) + for (let i = 0; i < 10; i++) { + const result = await autoRegisterProduct(`rate-limit-test-${i}`, 'user-123'); + expect(result.registered).toBe(true); + } + + // 11th should be rate limited + const result = await autoRegisterProduct('rate-limit-test-overflow', 'user-123'); + expect(result.registered).toBe(false); + expect(result.reason).toBe('Auto-registration rate limit exceeded'); + }); + + it('should generate correct licensePrefix from productId', async () => { + const result = await autoRegisterProduct('my-cool-app', 'user-123'); + + expect(result.registered).toBe(true); + expect(result.product!.licensePrefix).toBe('MYCOOLAP'); + }); +}); diff --git a/services/platform-service/src/lib/auto-register.ts b/services/platform-service/src/lib/auto-register.ts new file mode 100644 index 00000000..370c1bdb --- /dev/null +++ b/services/platform-service/src/lib/auto-register.ts @@ -0,0 +1,146 @@ +/** + * Auto-Registration — zero-touch product provisioning. + * + * When a request arrives with an unknown productId AND a valid JWT, + * auto-create a minimal ProductDoc instead of rejecting the request. + * This enables new products to start using platform-service immediately + * without manual admin setup. + * + * Security: + * - Requires valid JWT (unauthenticated requests still rejected) + * - Rate-limited: max 10 auto-registrations per minute + * - productId must match /^[a-z0-9_-]+$/ (same as CreateProductSchema) + * - Audit-logged as `product.auto_registered` + */ + +import * as productRepo from '../modules/products/repository.js'; +import { loadProductCache } from '../modules/products/cache.js'; +import * as auditRepo from '../modules/audit/repository.js'; +import type { ProductDoc } from '../modules/products/types.js'; + +const PRODUCT_ID_PATTERN = /^[a-z0-9_-]+$/; +const MAX_PRODUCT_ID_LENGTH = 64; + +// ── Rate limiter (sliding window, in-process) ─────────────────── + +const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute +const RATE_LIMIT_MAX = 10; +const registrationTimestamps: number[] = []; + +function isRateLimited(): boolean { + const now = Date.now(); + // Prune expired entries + while ( + registrationTimestamps.length > 0 && + registrationTimestamps[0]! < now - RATE_LIMIT_WINDOW_MS + ) { + registrationTimestamps.shift(); + } + return registrationTimestamps.length >= RATE_LIMIT_MAX; +} + +function recordRegistration(): void { + registrationTimestamps.push(Date.now()); +} + +/** Reset rate limiter (for testing). */ +export function resetRateLimiter(): void { + registrationTimestamps.length = 0; +} + +// ── Auto-Registration ─────────────────────────────────────────── + +export interface AutoRegisterResult { + registered: boolean; + product?: ProductDoc; + reason?: string; +} + +/** + * Attempt to auto-register an unknown productId. + * + * @param productId The unknown product identifier + * @param userId The authenticated user's ID (from JWT) + * @returns Result indicating whether registration succeeded + */ +export async function autoRegisterProduct( + productId: string, + userId: string +): Promise { + // Validate productId format + if ( + !productId || + productId.length > MAX_PRODUCT_ID_LENGTH || + !PRODUCT_ID_PATTERN.test(productId) + ) { + return { registered: false, reason: 'Invalid productId format' }; + } + + // Rate limit check + if (isRateLimited()) { + return { registered: false, reason: 'Auto-registration rate limit exceeded' }; + } + + // Double-check it doesn't already exist (race condition guard) + const existing = await productRepo.getById(productId); + if (existing) { + // Already exists — just refresh cache and proceed + await loadProductCache(); + return { registered: false, product: existing, reason: 'Already exists' }; + } + + // Build minimal product doc + const now = new Date().toISOString(); + const displayName = productId.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + const licensePrefix = productId.replace(/[-_]/g, '').slice(0, 8).toUpperCase() || 'PROD'; + + const doc: ProductDoc = { + id: productId, + productId, + displayName, + licensePrefix, + packageName: '', + defaultPlan: 'free', + trialDays: 14, + deviceLimits: { free: 1, pro: 3, enterprise: 10 }, + websiteUrl: '', + status: 'active', + createdAt: now, + updatedAt: now, + }; + + try { + const created = await productRepo.create(doc); + await loadProductCache(); + recordRegistration(); + + // Audit log (best-effort — don't fail if audit write fails) + try { + await auditRepo.create({ + id: `audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + productId, + userId, + action: 'product.auto_registered', + category: 'product', + details: { + displayName: created.displayName, + registeredBy: userId, + method: 'auto-registration', + }, + createdAt: now, + }); + } catch { + // Best-effort audit + } + + return { registered: true, product: created }; + } catch (err) { + // If create fails due to conflict (race), treat as success + const message = err instanceof Error ? err.message : 'Unknown error'; + if (message.includes('Conflict') || message.includes('409')) { + await loadProductCache(); + return { registered: false, reason: 'Concurrent registration' }; + } + return { registered: false, reason: message }; + } +} diff --git a/services/platform-service/src/lib/request-context.ts b/services/platform-service/src/lib/request-context.ts index 29c24548..ff39d7f6 100644 --- a/services/platform-service/src/lib/request-context.ts +++ b/services/platform-service/src/lib/request-context.ts @@ -13,6 +13,7 @@ import type { FastifyRequest } from 'fastify'; import { BadRequestError } from './errors.js'; import { isValidProduct, getProduct } from '../modules/products/cache.js'; import type { ProductDoc } from '../modules/products/types.js'; +import { autoRegisterProduct } from './auto-register.js'; /** JWT payload shape attached to req by the onRequest hook (see Commit 3). */ export interface JwtPayload { @@ -35,6 +36,44 @@ declare module 'fastify' { * Priority: JWT token > X-Product-Id header > env fallback (dev only). * Validates the product exists in the registry but does NOT check status. */ +export async function extractProductIdAsync(req: FastifyRequest): Promise { + // 1. From JWT (set during login/register, attached by onRequest hook) + let id = req.jwtPayload?.productId; + + // 2. From header (unauthenticated requests like license activation) + if (!id) { + const header = req.headers['x-product-id']; + if (typeof header === 'string' && header.length > 0) id = header; + } + + // 3. Fallback to env var (backward compat during migration, dev only) + if (!id) { + const envFallback = process.env.PRODUCT_ID; + if (envFallback) id = envFallback; + } + + if (!id) throw new BadRequestError('productId is required (via JWT or X-Product-Id header)'); + + // Auto-register unknown products when requester has a valid JWT + if (!isValidProduct(id) && req.jwtPayload?.sub) { + const result = await autoRegisterProduct(id, req.jwtPayload.sub); + if ( + !result.registered && + result.reason !== 'Already exists' && + result.reason !== 'Concurrent registration' + ) { + throw new BadRequestError( + `Unknown product: ${id} (auto-registration failed: ${result.reason})` + ); + } + } else if (!isValidProduct(id)) { + throw new BadRequestError(`Unknown product: ${id}`); + } + + return id; +} + +/** Synchronous wrapper kept for backward compatibility on hot paths. */ function extractProductId(req: FastifyRequest): string { // 1. From JWT (set during login/register, attached by onRequest hook) let id = req.jwtPayload?.productId;