feat(platform-service): add profile updates, tokens, and themes modules

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)
This commit is contained in:
saravanakumardb1 2026-02-15 17:29:43 -08:00
parent be3f5459bd
commit b977e85bc2
10 changed files with 626 additions and 1 deletions

View File

@ -117,7 +117,9 @@ export async function countByPlan(productId: string): Promise<Record<string, num
export async function update(
id: string,
updates: Partial<Pick<UserDoc, 'displayName' | 'role' | 'plan' | 'status'>>
updates: Partial<
Pick<UserDoc, 'displayName' | 'role' | 'plan' | 'status' | 'phone' | 'bio' | 'avatarUrl'>
>
): Promise<UserDoc | null> {
try {
const { resource } = await container().item(id, id).read<UserDoc>();

View File

@ -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.

View File

@ -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<typeof LoginSchema>;
export type RegisterInput = z.infer<typeof RegisterSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
export type SsoLoginInput = z.infer<typeof SsoLoginSchema>;
export type ProfileUpdateInput = z.infer<typeof ProfileUpdateSchema>;

View File

@ -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<ThemeDoc[]> {
const { resources } = await container()
.items.query<ThemeDoc>({
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<ThemeDoc | null> {
try {
const { resource } = await container().item(id, id).read<ThemeDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function getActive(productId: string): Promise<ThemeDoc | null> {
const { resources } = await container()
.items.query<ThemeDoc>({
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<ThemeDoc> {
const { resource } = await container().items.create(theme);
return resource as ThemeDoc;
}
export async function update(
id: string,
updates: Record<string, unknown>
): Promise<ThemeDoc | null> {
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<boolean> {
const theme = await getById(id);
if (!theme) return false;
// Deactivate all others for this product
const { resources: activeThemes } = await container()
.items.query<ThemeDoc>({
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<boolean> {
try {
await container().item(id, id).delete();
return true;
} catch {
return false;
}
}

View File

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

View File

@ -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(),
});

View File

@ -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<ApiTokenResponse[]> {
const { resources } = await container()
.items.query<ApiTokenDoc>({
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<ApiTokenResponse[]> {
const { resources } = await container()
.items.query<ApiTokenDoc>({
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<ApiTokenDoc | null> {
try {
const { resource } = await container().item(id, userId).read<ApiTokenDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function create(doc: ApiTokenDoc): Promise<ApiTokenResponse> {
const { resource } = await container().items.create<ApiTokenDoc>(doc);
return stripHash(resource!);
}
export async function revoke(id: string, userId: string): Promise<boolean> {
const existing = await getById(id, userId);
if (!existing) return false;
await container()
.item(id, userId)
.replace<ApiTokenDoc>({
...existing,
status: 'revoked',
});
return true;
}
export async function remove(id: string, userId: string): Promise<boolean> {
try {
await container().item(id, userId).delete();
return true;
} catch {
return false;
}
}
export async function countActive(productId: string): Promise<number> {
const { resources } = await container()
.items.query<number>({
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<string> {
return bcrypt.hash(raw, 10);
}

View File

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

View File

@ -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<ApiTokenDoc, 'tokenHash'>;
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']),
});

View File

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