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:
parent
f3c53b3331
commit
1fe1e75999
109
services/platform-service/src/lib/auto-register.test.ts
Normal file
109
services/platform-service/src/lib/auto-register.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
146
services/platform-service/src/lib/auto-register.ts
Normal file
146
services/platform-service/src/lib/auto-register.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ import type { FastifyRequest } from 'fastify';
|
|||||||
import { BadRequestError } from './errors.js';
|
import { BadRequestError } from './errors.js';
|
||||||
import { isValidProduct, getProduct } from '../modules/products/cache.js';
|
import { isValidProduct, getProduct } from '../modules/products/cache.js';
|
||||||
import type { ProductDoc } from '../modules/products/types.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). */
|
/** JWT payload shape attached to req by the onRequest hook (see Commit 3). */
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
@ -35,6 +36,44 @@ declare module 'fastify' {
|
|||||||
* Priority: JWT token > X-Product-Id header > env fallback (dev only).
|
* Priority: JWT token > X-Product-Id header > env fallback (dev only).
|
||||||
* Validates the product exists in the registry but does NOT check status.
|
* 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 {
|
function extractProductId(req: FastifyRequest): string {
|
||||||
// 1. From JWT (set during login/register, attached by onRequest hook)
|
// 1. From JWT (set during login/register, attached by onRequest hook)
|
||||||
let id = req.jwtPayload?.productId;
|
let id = req.jwtPayload?.productId;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user