From d1b3faae8b153fae607ccd4f7f2ec9e17da4e995 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 15 Mar 2026 06:19:48 +0000 Subject: [PATCH] fix(tokens): issue machine-ready defaults --- .../src/modules/tokens/routes.test.ts | 62 ++++++++++++++++++- .../src/modules/tokens/routes.ts | 19 +++++- .../src/modules/tokens/tokens.test.ts | 4 +- .../src/modules/tokens/types.ts | 4 +- 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/services/platform-service/src/modules/tokens/routes.test.ts b/services/platform-service/src/modules/tokens/routes.test.ts index cb171ad9..d670ec97 100644 --- a/services/platform-service/src/modules/tokens/routes.test.ts +++ b/services/platform-service/src/modules/tokens/routes.test.ts @@ -57,6 +57,7 @@ describe('tokenRoutes', () => { afterEach(() => { vi.restoreAllMocks(); + delete process.env.PLATFORM_RUNTIME_ENV; }); it('GET /tokens returns 401 when unauthenticated', async () => { @@ -121,13 +122,70 @@ describe('tokenRoutes', () => { expect(data.rawToken.startsWith('wai_')).toBe(true); expect(repoMock.create).toHaveBeenCalledWith( expect.objectContaining({ - tokenType: 'user_api', - environment: 'prod', + tokenType: 'product_api', + environment: 'dev', }) ); expect(repoMock.hashToken).toHaveBeenCalled(); }); + it('POST /tokens respects explicit environment overrides', async () => { + repoMock.hashToken.mockResolvedValue('hashed_token'); + repoMock.create.mockResolvedValue(baseToken); + const app = await buildApp({ + sub: 'admin_1', + productId: 'lysnrai', + role: 'admin', + email: 'admin@example.com', + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/tokens', + payload: { + name: 'staging-token', + tokenType: 'service_api', + environment: 'staging', + scopes: ['jobs:read'], + expiresInDays: 30, + }, + }); + + expect(res.statusCode).toBe(201); + expect(repoMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + tokenType: 'service_api', + environment: 'staging', + }) + ); + }); + + it('POST /tokens defaults environment from runtime configuration', async () => { + process.env.PLATFORM_RUNTIME_ENV = 'staging'; + repoMock.hashToken.mockResolvedValue('hashed_token'); + repoMock.create.mockResolvedValue(baseToken); + const app = await buildApp({ + sub: 'admin_1', + productId: 'lysnrai', + role: 'admin', + email: 'admin@example.com', + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/tokens', + payload: { name: 'runtime-default-token', scopes: ['jobs:read'], expiresInDays: 30 }, + }); + + expect(res.statusCode).toBe(201); + expect(repoMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + tokenType: 'product_api', + environment: 'staging', + }) + ); + }); + it('GET /tokens/count returns count for admin', async () => { repoMock.countActive.mockResolvedValue(4); const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); diff --git a/services/platform-service/src/modules/tokens/routes.ts b/services/platform-service/src/modules/tokens/routes.ts index d3894452..890566c5 100644 --- a/services/platform-service/src/modules/tokens/routes.ts +++ b/services/platform-service/src/modules/tokens/routes.ts @@ -14,6 +14,22 @@ import { BadRequestError, ForbiddenError, UnauthorizedError } from '../../lib/er import { CreateTokenSchema, PatchTokenSchema, type ApiTokenDoc } from './types.js'; import * as repo from './repository.js'; +function getRuntimeEnvironment(): 'dev' | 'staging' | 'prod' { + const explicit = process.env.PLATFORM_RUNTIME_ENV; + if (explicit === 'dev' || explicit === 'staging' || explicit === 'prod') { + return explicit; + } + + switch (process.env.NODE_ENV) { + case 'production': + return 'prod'; + case 'test': + return 'dev'; + default: + return 'dev'; + } +} + export async function tokenRoutes(app: FastifyInstance) { function requireAuth(req: import('fastify').FastifyRequest) { const payload = req.jwtPayload; @@ -51,6 +67,7 @@ export async function tokenRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const { name, tokenType, environment, scopes, expiresInDays } = parsed.data; + const resolvedEnvironment = environment ?? getRuntimeEnvironment(); const rawToken = `wai_${crypto.randomBytes(32).toString('hex')}`; const prefix = rawToken.slice(0, 12); @@ -65,7 +82,7 @@ export async function tokenRoutes(app: FastifyInstance) { userName: payload.email ?? '', name, tokenType, - environment, + environment: resolvedEnvironment, prefix, tokenHash, status: 'active', diff --git a/services/platform-service/src/modules/tokens/tokens.test.ts b/services/platform-service/src/modules/tokens/tokens.test.ts index c74a8b66..46278a98 100644 --- a/services/platform-service/src/modules/tokens/tokens.test.ts +++ b/services/platform-service/src/modules/tokens/tokens.test.ts @@ -12,8 +12,8 @@ describe('CreateTokenSchema', () => { expect(result.success).toBe(true); if (result.success) { expect(result.data.name).toBe('CI Pipeline'); - expect(result.data.tokenType).toBe('user_api'); - expect(result.data.environment).toBe('prod'); + expect(result.data.tokenType).toBe('product_api'); + expect(result.data.environment).toBeUndefined(); expect(result.data.scopes).toEqual(['read']); expect(result.data.expiresInDays).toBe(90); } diff --git a/services/platform-service/src/modules/tokens/types.ts b/services/platform-service/src/modules/tokens/types.ts index 3d35064d..1b7b7ed2 100644 --- a/services/platform-service/src/modules/tokens/types.ts +++ b/services/platform-service/src/modules/tokens/types.ts @@ -26,8 +26,8 @@ export type ApiTokenResponse = Omit; export const CreateTokenSchema = z.object({ name: z.string().min(1), - tokenType: z.enum(['user_api', 'product_api', 'service_api']).default('user_api'), - environment: z.enum(['dev', 'staging', 'prod']).default('prod'), + tokenType: z.enum(['user_api', 'product_api', 'service_api']).default('product_api'), + environment: z.enum(['dev', 'staging', 'prod']).optional(), scopes: z.array(z.string()).default(['read']), expiresInDays: z.number().int().min(1).max(365).default(90), });