feat(tokens): classify machine credentials

This commit is contained in:
root 2026-03-15 06:09:42 +00:00
parent 3398d1400f
commit 507f0fdd1f
6 changed files with 36 additions and 3 deletions

View File

@ -14,6 +14,8 @@ const baseToken: ApiTokenDoc = {
userId: 'user_1', userId: 'user_1',
userName: 'Test User', userName: 'Test User',
name: 'Test Token', name: 'Test Token',
tokenType: 'user_api',
environment: 'prod',
tokenHash: 'hash_abc', tokenHash: 'hash_abc',
prefix: 'lysnr_', prefix: 'lysnr_',
status: 'active', status: 'active',

View File

@ -23,6 +23,8 @@ const baseToken = {
userId: 'admin_1', userId: 'admin_1',
userName: 'admin@example.com', userName: 'admin@example.com',
name: 'ci-token', name: 'ci-token',
tokenType: 'service_api',
environment: 'prod',
prefix: 'wai_12345678', prefix: 'wai_12345678',
status: 'active', status: 'active',
scopes: ['read'], scopes: ['read'],
@ -117,6 +119,12 @@ describe('tokenRoutes', () => {
const data = JSON.parse(res.body); const data = JSON.parse(res.body);
expect(data).toHaveProperty('rawToken'); expect(data).toHaveProperty('rawToken');
expect(data.rawToken.startsWith('wai_')).toBe(true); expect(data.rawToken.startsWith('wai_')).toBe(true);
expect(repoMock.create).toHaveBeenCalledWith(
expect.objectContaining({
tokenType: 'user_api',
environment: 'prod',
})
);
expect(repoMock.hashToken).toHaveBeenCalled(); expect(repoMock.hashToken).toHaveBeenCalled();
}); });

View File

@ -50,7 +50,7 @@ export async function tokenRoutes(app: FastifyInstance) {
if (!parsed.success) { if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
} }
const { name, scopes, expiresInDays } = parsed.data; const { name, tokenType, environment, scopes, expiresInDays } = parsed.data;
const rawToken = `wai_${crypto.randomBytes(32).toString('hex')}`; const rawToken = `wai_${crypto.randomBytes(32).toString('hex')}`;
const prefix = rawToken.slice(0, 12); const prefix = rawToken.slice(0, 12);
@ -64,6 +64,8 @@ export async function tokenRoutes(app: FastifyInstance) {
userId: payload.sub, userId: payload.sub,
userName: payload.email ?? '', userName: payload.email ?? '',
name, name,
tokenType,
environment,
prefix, prefix,
tokenHash, tokenHash,
status: 'active', status: 'active',

View File

@ -12,6 +12,8 @@ describe('CreateTokenSchema', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) { if (result.success) {
expect(result.data.name).toBe('CI Pipeline'); expect(result.data.name).toBe('CI Pipeline');
expect(result.data.tokenType).toBe('user_api');
expect(result.data.environment).toBe('prod');
expect(result.data.scopes).toEqual(['read']); expect(result.data.scopes).toEqual(['read']);
expect(result.data.expiresInDays).toBe(90); expect(result.data.expiresInDays).toBe(90);
} }
@ -20,11 +22,15 @@ describe('CreateTokenSchema', () => {
it('accepts full input', () => { it('accepts full input', () => {
const result = CreateTokenSchema.safeParse({ const result = CreateTokenSchema.safeParse({
name: 'Deploy Key', name: 'Deploy Key',
tokenType: 'service_api',
environment: 'staging',
scopes: ['read', 'write', 'admin'], scopes: ['read', 'write', 'admin'],
expiresInDays: 30, expiresInDays: 30,
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) { if (result.success) {
expect(result.data.tokenType).toBe('service_api');
expect(result.data.environment).toBe('staging');
expect(result.data.scopes).toEqual(['read', 'write', 'admin']); expect(result.data.scopes).toEqual(['read', 'write', 'admin']);
expect(result.data.expiresInDays).toBe(30); expect(result.data.expiresInDays).toBe(30);
} }
@ -102,6 +108,8 @@ describe('ApiTokenDoc type shape', () => {
userId: 'user_1', userId: 'user_1',
userName: 'admin@example.com', userName: 'admin@example.com',
name: 'CI Token', name: 'CI Token',
tokenType: 'service_api',
environment: 'prod',
prefix: 'wai_abc12345', prefix: 'wai_abc12345',
tokenHash: '$2a$10$hashedvalue', tokenHash: '$2a$10$hashedvalue',
status: 'active', status: 'active',
@ -113,6 +121,8 @@ describe('ApiTokenDoc type shape', () => {
}; };
expect(doc.status).toBe('active'); expect(doc.status).toBe('active');
expect(doc.tokenType).toBe('service_api');
expect(doc.environment).toBe('prod');
expect(doc.scopes).toContain('read'); expect(doc.scopes).toContain('read');
expect(doc.lastUsed).toBeNull(); expect(doc.lastUsed).toBeNull();
}); });
@ -126,6 +136,8 @@ describe('ApiTokenDoc type shape', () => {
userId: 'u1', userId: 'u1',
userName: 'test', userName: 'test',
name: 'test', name: 'test',
tokenType: 'user_api',
environment: 'prod',
prefix: 'wai_', prefix: 'wai_',
tokenHash: 'hash', tokenHash: 'hash',
status, status,
@ -147,6 +159,8 @@ describe('ApiTokenResponse type shape', () => {
userId: 'u1', userId: 'u1',
userName: 'test@test.com', userName: 'test@test.com',
name: 'My Token', name: 'My Token',
tokenType: 'product_api',
environment: 'staging',
prefix: 'wai_abc', prefix: 'wai_abc',
tokenHash: 'secret_hash', tokenHash: 'secret_hash',
status: 'active', status: 'active',
@ -157,11 +171,14 @@ describe('ApiTokenResponse type shape', () => {
}; };
// Simulate what stripHash does // Simulate what stripHash does
const { tokenHash: _hash, ...response } = doc; const { tokenHash: _tokenHash, ...response } = doc;
void _tokenHash;
const apiResponse: ApiTokenResponse = response; const apiResponse: ApiTokenResponse = response;
expect(apiResponse.id).toBe('tok_1'); expect(apiResponse.id).toBe('tok_1');
expect(apiResponse.name).toBe('My Token'); expect(apiResponse.name).toBe('My Token');
expect(apiResponse.tokenType).toBe('product_api');
expect(apiResponse.environment).toBe('staging');
expect('tokenHash' in apiResponse).toBe(false); expect('tokenHash' in apiResponse).toBe(false);
}); });
}); });

View File

@ -10,6 +10,8 @@ export interface ApiTokenDoc {
userId: string; userId: string;
userName: string; userName: string;
name: string; name: string;
tokenType: 'user_api' | 'product_api' | 'service_api';
environment: 'dev' | 'staging' | 'prod';
prefix: string; prefix: string;
tokenHash: string; tokenHash: string;
status: 'active' | 'revoked' | 'expired'; status: 'active' | 'revoked' | 'expired';
@ -24,6 +26,8 @@ export type ApiTokenResponse = Omit<ApiTokenDoc, 'tokenHash'>;
export const CreateTokenSchema = z.object({ export const CreateTokenSchema = z.object({
name: z.string().min(1), 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'),
scopes: z.array(z.string()).default(['read']), scopes: z.array(z.string()).default(['read']),
expiresInDays: z.number().int().min(1).max(365).default(90), expiresInDays: z.number().int().min(1).max(365).default(90),
}); });

View File

@ -101,7 +101,7 @@ describe('server bootstrap', () => {
expect(appMock.register).toHaveBeenCalled(); expect(appMock.register).toHaveBeenCalled();
expect(appMock.register.mock.calls.length).toBeGreaterThan(15); expect(appMock.register.mock.calls.length).toBeGreaterThan(15);
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4003, host: '0.0.0.0' }); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4003, host: '0.0.0.0' });
}); }, 15000);
it('registers optional jwt parsing with the shared fastify-core helper', async () => { it('registers optional jwt parsing with the shared fastify-core helper', async () => {
await import('./server.js'); await import('./server.js');