From b977e85bc2943a1d100109bf2b821c3acb7a3291 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 15 Feb 2026 17:29:43 -0800 Subject: [PATCH] feat(platform-service): add profile updates, tokens, and themes modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth: - PUT /auth/profile — self-service profile update (displayName, phone, bio, avatarUrl) - ProfileUpdateSchema added to types.ts - Repository update() expanded to accept profile fields Tokens module (new): - GET /tokens — list tokens (admin: all, user: own) - POST /tokens — create API token (admin only) - GET /tokens/count — count active tokens - PATCH /tokens/:id — revoke token (admin only) - DELETE /tokens/:id — delete token (super_admin only) Themes module (new): - GET /themes — list all themes (admin only) - POST /themes — create theme (admin only) - GET /themes/active — get active theme (public, no auth) - GET /themes/:id — get theme by id (admin only) - PUT /themes/:id — update theme (admin only) - DELETE /themes/:id — delete theme (admin only) - POST /themes/:id/activate — set theme as active (admin only) --- .../src/modules/auth/repository.ts | 4 +- .../src/modules/auth/routes.ts | 20 +++ .../src/modules/auth/types.ts | 11 ++ .../src/modules/themes/repository.ts | 103 +++++++++++ .../src/modules/themes/routes.ts | 166 ++++++++++++++++++ .../src/modules/themes/types.ts | 71 ++++++++ .../src/modules/tokens/repository.ts | 95 ++++++++++ .../src/modules/tokens/routes.ts | 118 +++++++++++++ .../src/modules/tokens/types.ts | 33 ++++ services/platform-service/src/server.ts | 6 + 10 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 services/platform-service/src/modules/themes/repository.ts create mode 100644 services/platform-service/src/modules/themes/routes.ts create mode 100644 services/platform-service/src/modules/themes/types.ts create mode 100644 services/platform-service/src/modules/tokens/repository.ts create mode 100644 services/platform-service/src/modules/tokens/routes.ts create mode 100644 services/platform-service/src/modules/tokens/types.ts diff --git a/services/platform-service/src/modules/auth/repository.ts b/services/platform-service/src/modules/auth/repository.ts index a547e8aa..d030dc98 100644 --- a/services/platform-service/src/modules/auth/repository.ts +++ b/services/platform-service/src/modules/auth/repository.ts @@ -117,7 +117,9 @@ export async function countByPlan(productId: string): Promise> + updates: Partial< + Pick + > ): Promise { try { const { resource } = await container().item(id, id).read(); diff --git a/services/platform-service/src/modules/auth/routes.ts b/services/platform-service/src/modules/auth/routes.ts index 51e1406a..e4b097ae 100644 --- a/services/platform-service/src/modules/auth/routes.ts +++ b/services/platform-service/src/modules/auth/routes.ts @@ -5,6 +5,7 @@ * POST /auth/register — register new user * POST /auth/refresh — refresh access token * GET /auth/me — get current user from token + * PUT /auth/profile — update own profile (displayName, phone, bio, avatarUrl) * POST /auth/sso — SSO login (verified external identity) * POST /auth/verify — service-to-service token verification * @@ -29,6 +30,7 @@ import { RefreshSchema, UpdateUserSchema, SsoLoginSchema, + ProfileUpdateSchema, type UserDoc, } from './types.js'; @@ -231,6 +233,24 @@ export async function authRoutes(app: FastifyInstance) { } }); + // Self-service profile update (authenticated user updates their own profile) + app.put('/auth/profile', async req => { + const payload = req.jwtPayload; + if (!payload?.sub) throw new UnauthorizedError('Authentication required'); + + const parsed = ProfileUpdateSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const updated = await repo.update(payload.sub, parsed.data); + if (!updated) throw new BadRequestError('User not found'); + + const { passwordHash: _h, ...safe } = updated; + void _h; + return safe; + }); + // SSO login — verified external identity (called by dashboard OAuth callbacks) // The caller has already verified the user's identity via Microsoft/Google OAuth. // This endpoint finds or creates the user and issues platform JWT tokens. diff --git a/services/platform-service/src/modules/auth/types.ts b/services/platform-service/src/modules/auth/types.ts index 0133eca8..2716c9c0 100644 --- a/services/platform-service/src/modules/auth/types.ts +++ b/services/platform-service/src/modules/auth/types.ts @@ -16,6 +16,9 @@ export interface UserDoc { lastLoginAt: string | null; createdAt: string; updatedAt: string; + phone?: string | null; + bio?: string | null; + avatarUrl?: string | null; } export interface TokenPayload { @@ -60,7 +63,15 @@ export const SsoLoginSchema = z.object({ displayName: z.string().min(1).optional(), }); +export const ProfileUpdateSchema = z.object({ + displayName: z.string().min(1).optional(), + phone: z.string().optional().nullable(), + bio: z.string().optional().nullable(), + avatarUrl: z.string().url().optional().nullable(), +}); + export type LoginInput = z.infer; export type RegisterInput = z.infer; export type UpdateUserInput = z.infer; export type SsoLoginInput = z.infer; +export type ProfileUpdateInput = z.infer; diff --git a/services/platform-service/src/modules/themes/repository.ts b/services/platform-service/src/modules/themes/repository.ts new file mode 100644 index 00000000..0e65e552 --- /dev/null +++ b/services/platform-service/src/modules/themes/repository.ts @@ -0,0 +1,103 @@ +/** + * Theme repository — Cosmos DB. + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { ThemeDoc } from './types.js'; + +function container() { + return getContainer('themes'); +} + +export async function getAll(productId: string): Promise { + const { resources } = await container() + .items.query({ + query: + "SELECT * FROM c WHERE c.productId = @productId AND c.type = 'theme' ORDER BY c.created_at DESC", + parameters: [{ name: '@productId', value: productId }], + }) + .fetchAll(); + return resources; +} + +export async function getById(id: string): Promise { + try { + const { resource } = await container().item(id, id).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function getActive(productId: string): Promise { + const { resources } = await container() + .items.query({ + query: + "SELECT * FROM c WHERE c.productId = @productId AND c.type = 'theme' AND c.is_active = true ORDER BY c.is_default DESC, c.created_at DESC OFFSET 0 LIMIT 1", + parameters: [{ name: '@productId', value: productId }], + }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function create(theme: ThemeDoc): Promise { + const { resource } = await container().items.create(theme); + return resource as ThemeDoc; +} + +export async function update( + id: string, + updates: Record +): Promise { + const existing = await getById(id); + if (!existing) return null; + + const merged = { + ...existing, + ...updates, + updated_at: new Date().toISOString(), + }; + + const { resource } = await container().item(id, id).replace(merged); + return resource as ThemeDoc; +} + +export async function setActive(id: string, productId: string): Promise { + const theme = await getById(id); + if (!theme) return false; + + // Deactivate all others for this product + const { resources: activeThemes } = await container() + .items.query({ + query: + "SELECT * FROM c WHERE c.productId = @productId AND c.type = 'theme' AND c.is_active = true AND c.id != @excludeId", + parameters: [ + { name: '@productId', value: productId }, + { name: '@excludeId', value: id }, + ], + }) + .fetchAll(); + + for (const item of activeThemes) { + await container() + .item(item.id, item.id) + .replace({ + ...item, + is_active: false, + updated_at: new Date().toISOString(), + }); + } + + // Activate the target theme + await update(id, { is_active: true }); + return true; +} + +export async function remove(id: string): Promise { + try { + await container().item(id, id).delete(); + return true; + } catch { + return false; + } +} diff --git a/services/platform-service/src/modules/themes/routes.ts b/services/platform-service/src/modules/themes/routes.ts new file mode 100644 index 00000000..e0acc260 --- /dev/null +++ b/services/platform-service/src/modules/themes/routes.ts @@ -0,0 +1,166 @@ +/** + * Theme REST endpoints. + * + * GET /themes — list all themes (admin only) + * POST /themes — create a new theme (admin only) + * GET /themes/active — get active theme (public, no auth) + * GET /themes/:id — get theme by id (admin only) + * PUT /themes/:id — update a theme (admin only) + * DELETE /themes/:id — delete a theme (admin only) + * POST /themes/:id/activate — set a theme as active (admin only) + */ + +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, ForbiddenError, UnauthorizedError } from '../../lib/errors.js'; +import { + CreateThemeSchema, + UpdateThemeSchema, + type ThemeDoc, + type PlatformTheme, +} from './types.js'; +import * as repo from './repository.js'; + +const DEFAULT_THEME = { + primary: '#4caf50', + secondary: '#2e7d32', + accent: '#66bb6a', + background: '#ffffff', + surface: '#f5f5f5', + error: '#f44336', + warning: '#ff9800', + success: '#4caf50', +}; + +const DEFAULT_DESKTOP_THEME = { + ...DEFAULT_THEME, + idle: '#4caf50', + listening: '#e94560', + processing: '#f5a623', + offline: '#9e9e9e', +}; + +export async function themeRoutes(app: FastifyInstance) { + function requireAdmin(req: import('fastify').FastifyRequest): string { + const role = req.jwtPayload?.role; + if (!role || !['super_admin', 'admin'].includes(role)) { + throw new ForbiddenError('Admin access required'); + } + const productId = req.jwtPayload?.productId; + if (!productId) throw new UnauthorizedError('Missing productId in token'); + return productId; + } + + // List all themes (admin only) + app.get('/themes', async req => { + const productId = requireAdmin(req); + return await repo.getAll(productId); + }); + + // Create a new theme (admin only) + app.post('/themes', async (req, reply) => { + const productId = requireAdmin(req); + const parsed = CreateThemeSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const { name, description, ios, android, desktop, is_active, is_default } = parsed.data; + const now = new Date().toISOString(); + + const theme: ThemeDoc = { + id: crypto.randomUUID(), + productId, + name, + description: description ?? null, + ios: (ios || DEFAULT_THEME) as PlatformTheme, + android: (android || DEFAULT_THEME) as PlatformTheme, + desktop: (desktop || DEFAULT_DESKTOP_THEME) as PlatformTheme, + is_active: is_active ?? false, + is_default: is_default ?? false, + version: '1.0', + created_at: now, + updated_at: now, + created_by: req.jwtPayload?.sub ?? null, + type: 'theme', + }; + + const created = await repo.create(theme); + reply.code(201); + return created; + }); + + // Get active theme (public — no auth required, used by mobile/desktop clients) + app.get('/themes/active', async req => { + // Accept productId from query param or JWT + const productId = (req.query as { productId?: string }).productId ?? req.jwtPayload?.productId; + if (!productId) throw new BadRequestError('productId query param is required'); + + const theme = await repo.getActive(productId); + if (theme) return theme; + + // Return default theme if none is active + return { + id: 'default', + productId, + name: 'Default Green', + description: 'Default green theme', + ios: DEFAULT_THEME, + android: DEFAULT_THEME, + desktop: DEFAULT_DESKTOP_THEME, + is_active: true, + is_default: true, + version: '1.0', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: null, + type: 'theme', + }; + }); + + // Get theme by id (admin only) + app.get('/themes/:id', async req => { + requireAdmin(req); + const { id } = req.params as { id: string }; + const theme = await repo.getById(id); + if (!theme) throw new BadRequestError('Theme not found'); + return theme; + }); + + // Update a theme (admin only) + app.put('/themes/:id', async req => { + requireAdmin(req); + const { id } = req.params as { id: string }; + const parsed = UpdateThemeSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + // If setting as active, deactivate others + if (parsed.data.is_active) { + const productId = req.jwtPayload?.productId; + if (productId) await repo.setActive(id, productId); + } + + const updated = await repo.update(id, parsed.data); + if (!updated) throw new BadRequestError('Theme not found'); + return updated; + }); + + // Delete a theme (admin only) + app.delete('/themes/:id', async req => { + requireAdmin(req); + const { id } = req.params as { id: string }; + const success = await repo.remove(id); + if (!success) throw new BadRequestError('Theme not found'); + return { success: true }; + }); + + // Activate a theme (admin only) + app.post('/themes/:id/activate', async req => { + const productId = requireAdmin(req); + const { id } = req.params as { id: string }; + const success = await repo.setActive(id, productId); + if (!success) throw new BadRequestError('Theme not found'); + const theme = await repo.getById(id); + return theme; + }); +} diff --git a/services/platform-service/src/modules/themes/types.ts b/services/platform-service/src/modules/themes/types.ts new file mode 100644 index 00000000..41c94577 --- /dev/null +++ b/services/platform-service/src/modules/themes/types.ts @@ -0,0 +1,71 @@ +/** + * Theme types — cross-platform theme configuration for iOS, Android, Desktop. + */ + +import { z } from 'zod'; + +export interface PlatformTheme { + primary: string; + secondary: string; + accent: string; + background: string; + surface: string; + error: string; + warning: string; + success: string; + idle?: string; + listening?: string; + processing?: string; + offline?: string; +} + +export interface ThemeDoc { + id: string; + productId: string; + name: string; + description: string | null; + ios: PlatformTheme; + android: PlatformTheme; + desktop: PlatformTheme; + is_active: boolean; + is_default: boolean; + version: string; + created_at: string; + updated_at: string; + created_by: string | null; + type: 'theme'; +} + +const PlatformThemeSchema = z.object({ + primary: z.string().optional(), + secondary: z.string().optional(), + accent: z.string().optional(), + background: z.string().optional(), + surface: z.string().optional(), + error: z.string().optional(), + warning: z.string().optional(), + success: z.string().optional(), + idle: z.string().optional(), + listening: z.string().optional(), + processing: z.string().optional(), + offline: z.string().optional(), +}); + +export const CreateThemeSchema = z.object({ + name: z.string().min(1), + description: z.string().nullable().optional(), + ios: PlatformThemeSchema.optional(), + android: PlatformThemeSchema.optional(), + desktop: PlatformThemeSchema.optional(), + is_active: z.boolean().optional(), + is_default: z.boolean().optional(), +}); + +export const UpdateThemeSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().nullable().optional(), + ios: PlatformThemeSchema.optional(), + android: PlatformThemeSchema.optional(), + desktop: PlatformThemeSchema.optional(), + is_active: z.boolean().optional(), +}); diff --git a/services/platform-service/src/modules/tokens/repository.ts b/services/platform-service/src/modules/tokens/repository.ts new file mode 100644 index 00000000..521f246a --- /dev/null +++ b/services/platform-service/src/modules/tokens/repository.ts @@ -0,0 +1,95 @@ +/** + * API token repository — Cosmos DB. + */ + +import bcrypt from 'bcryptjs'; +import { getContainer } from '../../lib/cosmos.js'; +import type { ApiTokenDoc, ApiTokenResponse } from './types.js'; + +function container() { + return getContainer('api_tokens'); +} + +function stripHash(doc: ApiTokenDoc): ApiTokenResponse { + const { tokenHash: _hash, ...rest } = doc; + void _hash; + return rest; +} + +export async function list(productId: string, limit = 100): Promise { + const { resources } = await container() + .items.query({ + query: + "SELECT * FROM c WHERE c.productId = @productId AND c.status != 'expired' ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit", + parameters: [ + { name: '@productId', value: productId }, + { name: '@limit', value: limit }, + ], + }) + .fetchAll(); + return resources.map(stripHash); +} + +export async function listByUser(userId: string, productId: string): Promise { + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC', + parameters: [ + { name: '@productId', value: productId }, + { name: '@userId', value: userId }, + ], + }) + .fetchAll(); + return resources.map(stripHash); +} + +export async function getById(id: string, userId: string): Promise { + try { + const { resource } = await container().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function create(doc: ApiTokenDoc): Promise { + const { resource } = await container().items.create(doc); + return stripHash(resource!); +} + +export async function revoke(id: string, userId: string): Promise { + const existing = await getById(id, userId); + if (!existing) return false; + + await container() + .item(id, userId) + .replace({ + ...existing, + status: 'revoked', + }); + return true; +} + +export async function remove(id: string, userId: string): Promise { + try { + await container().item(id, userId).delete(); + return true; + } catch { + return false; + } +} + +export async function countActive(productId: string): Promise { + const { resources } = await container() + .items.query({ + query: "SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status = 'active'", + parameters: [{ name: '@productId', value: productId }], + }) + .fetchAll(); + return resources[0] ?? 0; +} + +export async function hashToken(raw: string): Promise { + return bcrypt.hash(raw, 10); +} diff --git a/services/platform-service/src/modules/tokens/routes.ts b/services/platform-service/src/modules/tokens/routes.ts new file mode 100644 index 00000000..b4e42ec5 --- /dev/null +++ b/services/platform-service/src/modules/tokens/routes.ts @@ -0,0 +1,118 @@ +/** + * API Token REST endpoints. + * + * GET /tokens — list tokens (admin: all, user: own) + * POST /tokens — create a new API token (admin only) + * GET /tokens/count — count active tokens + * PATCH /tokens/:id — revoke a token (admin only) + * DELETE /tokens/:id — delete a token (super_admin only) + */ + +import type { FastifyInstance } from 'fastify'; +import crypto from 'crypto'; +import { BadRequestError, ForbiddenError, UnauthorizedError } from '../../lib/errors.js'; +import { CreateTokenSchema, PatchTokenSchema, type ApiTokenDoc } from './types.js'; +import * as repo from './repository.js'; + +export async function tokenRoutes(app: FastifyInstance) { + function requireAuth(req: import('fastify').FastifyRequest) { + const payload = req.jwtPayload; + if (!payload?.sub || !payload?.productId) { + throw new UnauthorizedError('Authentication required'); + } + return payload; + } + + function requireAdmin(req: import('fastify').FastifyRequest) { + const payload = requireAuth(req); + if (!['super_admin', 'admin'].includes(payload.role ?? '')) { + throw new ForbiddenError('Admin access required'); + } + return payload; + } + + // List tokens + app.get('/tokens', async req => { + const payload = requireAuth(req); + const isAdmin = ['super_admin', 'admin'].includes(payload.role ?? ''); + if (isAdmin) { + const tokens = await repo.list(payload.productId!); + return { tokens }; + } + const tokens = await repo.listByUser(payload.sub, payload.productId!); + return { tokens }; + }); + + // Create token (admin only) + app.post('/tokens', async (req, reply) => { + const payload = requireAdmin(req); + const parsed = CreateTokenSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const { name, scopes, expiresInDays } = parsed.data; + + const rawToken = `wai_${crypto.randomBytes(32).toString('hex')}`; + const prefix = rawToken.slice(0, 12); + const tokenHash = await repo.hashToken(rawToken); + const now = new Date(); + const expiresAt = new Date(now.getTime() + expiresInDays * 86400000); + + const doc: ApiTokenDoc = { + id: `tok_${crypto.randomUUID()}`, + productId: payload.productId!, + userId: payload.sub, + userName: payload.email ?? '', + name, + prefix, + tokenHash, + status: 'active', + scopes, + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + lastUsed: null, + ttl: expiresInDays * 86400, + }; + + const token = await repo.create(doc); + reply.code(201); + return { ...token, rawToken }; + }); + + // Count active tokens + app.get('/tokens/count', async req => { + const payload = requireAdmin(req); + const count = await repo.countActive(payload.productId!); + return { count }; + }); + + // Revoke token (admin only) + app.patch('/tokens/:id', async req => { + const payload = requireAdmin(req); + const { id } = req.params as { id: string }; + const parsed = PatchTokenSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + if (parsed.data.action === 'revoke') { + const success = await repo.revoke(id, payload.sub); + if (!success) throw new BadRequestError('Token not found'); + return { success: true }; + } + + throw new BadRequestError('Unknown action'); + }); + + // Delete token (super_admin only) + app.delete('/tokens/:id', async req => { + const payload = requireAuth(req); + if (payload.role !== 'super_admin') { + throw new ForbiddenError('Super admin access required'); + } + const { id } = req.params as { id: string }; + const success = await repo.remove(id, payload.sub); + if (!success) throw new BadRequestError('Token not found'); + return { success: true }; + }); +} diff --git a/services/platform-service/src/modules/tokens/types.ts b/services/platform-service/src/modules/tokens/types.ts new file mode 100644 index 00000000..bd4c5a93 --- /dev/null +++ b/services/platform-service/src/modules/tokens/types.ts @@ -0,0 +1,33 @@ +/** + * API token types — machine credentials for programmatic API access. + */ + +import { z } from 'zod'; + +export interface ApiTokenDoc { + id: string; + productId: string; + userId: string; + userName: string; + name: string; + prefix: string; + tokenHash: string; + status: 'active' | 'revoked' | 'expired'; + scopes: string[]; + createdAt: string; + expiresAt: string; + lastUsed: string | null; + ttl?: number; +} + +export type ApiTokenResponse = Omit; + +export const CreateTokenSchema = z.object({ + name: z.string().min(1), + scopes: z.array(z.string()).default(['read']), + expiresInDays: z.number().int().min(1).max(365).default(90), +}); + +export const PatchTokenSchema = z.object({ + action: z.enum(['revoke']), +}); diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 0264519c..c2f73e6d 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -43,6 +43,8 @@ import { commentRoutes } from './modules/comments/routes.js'; import { voteRoutes } from './modules/votes/routes.js'; import { memoryRoutes } from './modules/memory/routes.js'; import { publicRoutes } from './modules/public/routes.js'; +import { tokenRoutes } from './modules/tokens/routes.js'; +import { themeRoutes } from './modules/themes/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { config } from './lib/config.js'; @@ -107,6 +109,10 @@ await app.register(commentRoutes, { prefix: '/api' }); await app.register(voteRoutes, { prefix: '/api' }); // Mobile capture modules await app.register(memoryRoutes, { prefix: '/api' }); +// API tokens module +await app.register(tokenRoutes, { prefix: '/api' }); +// Themes module +await app.register(themeRoutes, { prefix: '/api' }); // Public routes — no auth, registered at top level await app.register(publicRoutes, { prefix: '/api' });