diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 5322c0d8..d1f1bb5f 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -3,6 +3,7 @@ import type { ContainerConfig } from '@bytelyst/cosmos'; import { config } from './config.js'; const CONTAINER_DEFS: Record = { + products: { partitionKeyPath: '/id' }, users: { partitionKeyPath: '/id' }, devices: { partitionKeyPath: '/userId' }, notification_prefs: { partitionKeyPath: '/userId' }, diff --git a/services/platform-service/src/modules/products/cache.ts b/services/platform-service/src/modules/products/cache.ts new file mode 100644 index 00000000..85e9f547 --- /dev/null +++ b/services/platform-service/src/modules/products/cache.ts @@ -0,0 +1,39 @@ +/** + * Products in-memory cache. + * Loaded on startup, refreshed after admin writes. + */ + +import * as repository from './repository.js'; +import type { ProductDoc } from './types.js'; + +const productCache = new Map(); + +/** + * Load all products from Cosmos into memory. + * Called on startup and after admin create/update. + */ +export async function loadProductCache(): Promise { + const all = await repository.getAll(); + productCache.clear(); + for (const p of all) productCache.set(p.id, p); +} + +/** Get a product by ID from cache. */ +export function getProduct(productId: string): ProductDoc | undefined { + return productCache.get(productId); +} + +/** Check if a productId exists in cache. */ +export function isValidProduct(productId: string): boolean { + return productCache.has(productId); +} + +/** Get all cached products. */ +export function getAllProducts(): ProductDoc[] { + return Array.from(productCache.values()); +} + +/** Current cache size (for tests/health). */ +export function cacheSize(): number { + return productCache.size; +} diff --git a/services/platform-service/src/modules/products/repository.ts b/services/platform-service/src/modules/products/repository.ts new file mode 100644 index 00000000..9f8f12e2 --- /dev/null +++ b/services/platform-service/src/modules/products/repository.ts @@ -0,0 +1,49 @@ +/** + * Products repository — Cosmos DB CRUD. + * Products are the central registry; productId is the partition key. + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { ProductDoc } from './types.js'; + +function container() { + return getContainer('products'); +} + +export async function getAll(): Promise { + const { resources } = await container() + .items.query({ + query: 'SELECT * FROM c ORDER BY c.displayName ASC', + }) + .fetchAll(); + return resources; +} + +export async function getById(productId: string): Promise { + try { + const { resource } = await container().item(productId, productId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function create(doc: ProductDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as ProductDoc; +} + +export async function update( + productId: string, + updates: Partial +): Promise { + try { + const { resource: existing } = await container().item(productId, productId).read(); + if (!existing) return null; + const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + const { resource } = await container().item(productId, productId).replace(merged); + return resource as ProductDoc; + } catch { + return null; + } +} diff --git a/services/platform-service/src/modules/products/routes.ts b/services/platform-service/src/modules/products/routes.ts new file mode 100644 index 00000000..56f90aff --- /dev/null +++ b/services/platform-service/src/modules/products/routes.ts @@ -0,0 +1,79 @@ +/** + * Products registry REST endpoints. + * + * GET /products — list all products (public, cached) + * GET /products/:id — get one product (public, cached) + * POST /products — create product (admin-only) + * PUT /products/:id — update product (admin-only, refreshes cache) + */ + +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, ConflictError, NotFoundError } from '../../lib/errors.js'; +import * as repo from './repository.js'; +import { loadProductCache, getAllProducts, getProduct } from './cache.js'; +import { CreateProductSchema, UpdateProductSchema, type ProductDoc } from './types.js'; + +export async function productRoutes(app: FastifyInstance) { + // List all products (served from cache) + app.get('/products', async () => { + return { products: getAllProducts() }; + }); + + // Get single product (served from cache) + app.get('/products/:id', async req => { + const { id } = req.params as { id: string }; + const product = getProduct(id); + if (!product) throw new NotFoundError('Product not found'); + return product; + }); + + // Create product (admin-only) + app.post('/products', async (req, reply) => { + const parsed = CreateProductSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const input = parsed.data; + + // Check for duplicate + const existing = await repo.getById(input.productId); + if (existing) throw new ConflictError(`Product "${input.productId}" already exists`); + + const now = new Date().toISOString(); + const doc: ProductDoc = { + id: input.productId, + ...input, + createdAt: now, + updatedAt: now, + }; + + const created = await repo.create(doc); + await loadProductCache(); + reply.code(201); + return created; + }); + + // Update product (admin-only, refreshes cache) + app.put('/products/:id', async req => { + const { id } = req.params as { id: string }; + const existing = await repo.getById(id); + if (!existing) throw new NotFoundError('Product not found'); + + const parsed = UpdateProductSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + // Merge deviceLimits if partial update provided + const { deviceLimits, ...rest } = parsed.data; + const updates: Partial = { ...rest }; + if (deviceLimits) { + updates.deviceLimits = { ...existing.deviceLimits, ...deviceLimits }; + } + + const updated = await repo.update(id, updates); + if (!updated) throw new NotFoundError('Product update failed'); + await loadProductCache(); + return updated; + }); +} diff --git a/services/platform-service/src/modules/products/types.ts b/services/platform-service/src/modules/products/types.ts new file mode 100644 index 00000000..bf7afd5a --- /dev/null +++ b/services/platform-service/src/modules/products/types.ts @@ -0,0 +1,70 @@ +/** + * Products registry types — central product configuration. + * Admin creates products, dev teams use the productId across their stack. + */ + +import { z } from 'zod'; + +export interface ProductDoc { + id: string; + productId: string; + displayName: string; + licensePrefix: string; + packageName: string; + defaultPlan: 'free' | 'pro'; + trialDays: number; + deviceLimits: { + free: number; + pro: number; + enterprise: number; + }; + websiteUrl: string; + status: 'active' | 'disabled'; + createdAt: string; + updatedAt: string; +} + +const DeviceLimitsSchema = z.object({ + free: z.number().int().min(0).default(1), + pro: z.number().int().min(0).default(3), + enterprise: z.number().int().min(0).default(10), +}); + +export const CreateProductSchema = z.object({ + productId: z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9_-]+$/, 'productId must be lowercase alphanumeric with hyphens/underscores'), + displayName: z.string().min(1).max(128), + licensePrefix: z + .string() + .min(1) + .max(16) + .regex(/^[A-Z]+$/, 'licensePrefix must be uppercase letters'), + packageName: z.string().min(1).max(256).default(''), + defaultPlan: z.enum(['free', 'pro']).default('free'), + trialDays: z.number().int().min(0).max(365).default(14), + deviceLimits: DeviceLimitsSchema.default({ free: 1, pro: 3, enterprise: 10 }), + websiteUrl: z.string().url().or(z.literal('')).default(''), + status: z.enum(['active', 'disabled']).default('active'), +}); + +export const UpdateProductSchema = z.object({ + displayName: z.string().min(1).max(128).optional(), + licensePrefix: z + .string() + .min(1) + .max(16) + .regex(/^[A-Z]+$/) + .optional(), + packageName: z.string().max(256).optional(), + defaultPlan: z.enum(['free', 'pro']).optional(), + trialDays: z.number().int().min(0).max(365).optional(), + deviceLimits: DeviceLimitsSchema.partial().optional(), + websiteUrl: z.string().url().or(z.literal('')).optional(), + status: z.enum(['active', 'disabled']).optional(), +}); + +export type CreateProductInput = z.infer; +export type UpdateProductInput = z.infer; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 2fe7113a..f7c35a7b 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -22,6 +22,8 @@ await resolveKeyVaultSecrets([ ]); import { createServiceApp, startService } from '@bytelyst/fastify-core'; +import { productRoutes } from './modules/products/routes.js'; +import { loadProductCache } from './modules/products/cache.js'; import { authRoutes } from './modules/auth/routes.js'; import { auditRoutes } from './modules/audit/routes.js'; import { notificationRoutes } from './modules/notifications/routes.js'; @@ -45,21 +47,25 @@ import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { config } from './lib/config.js'; await initCosmosIfNeeded(); +await loadProductCache(); const app = await createServiceApp({ name: 'platform-service', version: '0.1.0', - description: 'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos, subscriptions, usage, plans, licenses, stripe', + description: + 'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos, subscriptions, usage, plans, licenses, stripe', corsOrigin: config.CORS_ORIGIN, swagger: { title: 'Platform Service', - description: 'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos, subscriptions, usage, plans, licenses, stripe', + description: + 'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos, subscriptions, usage, plans, licenses, stripe', port: config.PORT, }, metrics: true, }); // Register route modules +await app.register(productRoutes, { prefix: '/api' }); await app.register(authRoutes, { prefix: '/api' }); await app.register(auditRoutes, { prefix: '/api' }); await app.register(notificationRoutes, { prefix: '/api' }); @@ -74,7 +80,7 @@ await app.register(promoRoutes, { prefix: '/api' }); // Scoped with internal key auth guard when BILLING_INTERNAL_KEY is set (Gap 3) const BILLING_KEY = config.BILLING_INTERNAL_KEY; if (BILLING_KEY) { - await app.register(async (billingScope) => { + await app.register(async billingScope => { billingScope.addHook('onRequest', async (req, reply) => { const key = req.headers['x-internal-key']; if (key !== BILLING_KEY) {