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(
|
||||
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>();
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>;
|
||||
|
||||
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 { 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' });
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user