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:
parent
be3f5459bd
commit
b977e85bc2
@ -117,7 +117,9 @@ export async function countByPlan(productId: string): Promise<Record<string, num
|
|||||||
|
|
||||||
export async function update(
|
export async function update(
|
||||||
id: string,
|
id: string,
|
||||||
updates: Partial<Pick<UserDoc, 'displayName' | 'role' | 'plan' | 'status'>>
|
updates: Partial<
|
||||||
|
Pick<UserDoc, 'displayName' | 'role' | 'plan' | 'status' | 'phone' | 'bio' | 'avatarUrl'>
|
||||||
|
>
|
||||||
): Promise<UserDoc | null> {
|
): Promise<UserDoc | null> {
|
||||||
try {
|
try {
|
||||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
* POST /auth/register — register new user
|
* POST /auth/register — register new user
|
||||||
* POST /auth/refresh — refresh access token
|
* POST /auth/refresh — refresh access token
|
||||||
* GET /auth/me — get current user from 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/sso — SSO login (verified external identity)
|
||||||
* POST /auth/verify — service-to-service token verification
|
* POST /auth/verify — service-to-service token verification
|
||||||
*
|
*
|
||||||
@ -29,6 +30,7 @@ import {
|
|||||||
RefreshSchema,
|
RefreshSchema,
|
||||||
UpdateUserSchema,
|
UpdateUserSchema,
|
||||||
SsoLoginSchema,
|
SsoLoginSchema,
|
||||||
|
ProfileUpdateSchema,
|
||||||
type UserDoc,
|
type UserDoc,
|
||||||
} from './types.js';
|
} 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)
|
// SSO login — verified external identity (called by dashboard OAuth callbacks)
|
||||||
// The caller has already verified the user's identity via Microsoft/Google OAuth.
|
// 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.
|
// This endpoint finds or creates the user and issues platform JWT tokens.
|
||||||
|
|||||||
@ -16,6 +16,9 @@ export interface UserDoc {
|
|||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
phone?: string | null;
|
||||||
|
bio?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenPayload {
|
export interface TokenPayload {
|
||||||
@ -60,7 +63,15 @@ export const SsoLoginSchema = z.object({
|
|||||||
displayName: z.string().min(1).optional(),
|
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 LoginInput = z.infer<typeof LoginSchema>;
|
||||||
export type RegisterInput = z.infer<typeof RegisterSchema>;
|
export type RegisterInput = z.infer<typeof RegisterSchema>;
|
||||||
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
||||||
export type SsoLoginInput = z.infer<typeof SsoLoginSchema>;
|
export type SsoLoginInput = z.infer<typeof SsoLoginSchema>;
|
||||||
|
export type ProfileUpdateInput = z.infer<typeof ProfileUpdateSchema>;
|
||||||
|
|||||||
103
services/platform-service/src/modules/themes/repository.ts
Normal file
103
services/platform-service/src/modules/themes/repository.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
166
services/platform-service/src/modules/themes/routes.ts
Normal file
166
services/platform-service/src/modules/themes/routes.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
71
services/platform-service/src/modules/themes/types.ts
Normal file
71
services/platform-service/src/modules/themes/types.ts
Normal 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(),
|
||||||
|
});
|
||||||
95
services/platform-service/src/modules/tokens/repository.ts
Normal file
95
services/platform-service/src/modules/tokens/repository.ts
Normal 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);
|
||||||
|
}
|
||||||
118
services/platform-service/src/modules/tokens/routes.ts
Normal file
118
services/platform-service/src/modules/tokens/routes.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
33
services/platform-service/src/modules/tokens/types.ts
Normal file
33
services/platform-service/src/modules/tokens/types.ts
Normal 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']),
|
||||||
|
});
|
||||||
@ -43,6 +43,8 @@ import { commentRoutes } from './modules/comments/routes.js';
|
|||||||
import { voteRoutes } from './modules/votes/routes.js';
|
import { voteRoutes } from './modules/votes/routes.js';
|
||||||
import { memoryRoutes } from './modules/memory/routes.js';
|
import { memoryRoutes } from './modules/memory/routes.js';
|
||||||
import { publicRoutes } from './modules/public/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 { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { config } from './lib/config.js';
|
import { config } from './lib/config.js';
|
||||||
|
|
||||||
@ -107,6 +109,10 @@ await app.register(commentRoutes, { prefix: '/api' });
|
|||||||
await app.register(voteRoutes, { prefix: '/api' });
|
await app.register(voteRoutes, { prefix: '/api' });
|
||||||
// Mobile capture modules
|
// Mobile capture modules
|
||||||
await app.register(memoryRoutes, { prefix: '/api' });
|
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
|
// Public routes — no auth, registered at top level
|
||||||
await app.register(publicRoutes, { prefix: '/api' });
|
await app.register(publicRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user