feat(platform-service): auto-registration on first request (Phase 4.1) — 8 tests

Zero-touch product provisioning: when a request arrives with an unknown
productId and a valid JWT, auto-create a minimal ProductDoc instead of
rejecting. Enables new products to use platform-service immediately.

- auto-register.ts: auto-create ProductDoc with sensible defaults
- Rate limited: max 10 auto-registrations per minute
- Requires valid JWT (unauthenticated requests still rejected)
- Audit logged as product.auto_registered
- request-context.ts: exported extractProductIdAsync with auto-register
- 8 tests: register, duplicate, format validation, rate limit
This commit is contained in:
saravanakumardb1 2026-03-19 22:00:57 -07:00
parent f3c53b3331
commit 1fe1e75999
3 changed files with 294 additions and 0 deletions

View File

@ -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<void> {
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');
});
});

View File

@ -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<AutoRegisterResult> {
// 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 };
}
}

View File

@ -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<string> {
// 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;