diff --git a/packages/schemas/index.js b/packages/schemas/index.js index 52812b913..04fe2a0e9 100644 --- a/packages/schemas/index.js +++ b/packages/schemas/index.js @@ -1,8 +1,8 @@ /** * @friggframework/schemas - Canonical JSON Schema definitions for Frigg Framework - * + * * This package provides formal JSON Schema definitions for all core Frigg configuration - * objects, along with runtime validation utilities. + * objects, along with runtime validation utilities and Express middleware. */ const Ajv = require('ajv'); @@ -10,6 +10,9 @@ const addFormats = require('ajv-formats'); const fs = require('fs'); const path = require('path'); +// Import middleware +const schemaValidationMiddleware = require('./middleware/schema-validation'); + // Initialize AJV with formats const ajv = new Ajv({ allErrors: true, @@ -25,11 +28,15 @@ const schemaDir = path.join(__dirname, 'schemas'); // Load schema files const schemaFiles = [ 'app-definition.schema.json', - 'integration-definition.schema.json', + 'integration-definition.schema.json', 'api-module-definition.schema.json', 'serverless-config.schema.json', 'environment-config.schema.json', - 'core-models.schema.json' + 'core-models.schema.json', + 'api-authorization.schema.json', + 'api-credentials.schema.json', + 'api-entities.schema.json', + 'api-proxy.schema.json' ]; schemaFiles.forEach(file => { @@ -117,6 +124,195 @@ function validateCoreModels(coreModels) { return validate('core-models', coreModels); } +/** + * Validate Authorization Requirements + * @param {object} requirements - Authorization requirements object to validate + * @returns {object} - Validation result + */ +function validateAuthorizationRequirements(requirements) { + return validate('api-authorization#/definitions/authorizationRequirements', requirements); +} + +/** + * Validate Authorization Request + * @param {object} request - Authorization request object to validate + * @returns {object} - Validation result + */ +function validateAuthorizationRequest(request) { + return validate('api-authorization#/definitions/authorizationRequest', request); +} + +/** + * Validate Authorization Response + * @param {object} response - Authorization response object to validate + * @returns {object} - Validation result + */ +function validateAuthorizationResponse(response) { + return validate('api-authorization#/definitions/authorizationResponse', response); +} + +/** + * Validate Authorization Session + * @param {object} session - Authorization session object to validate + * @returns {object} - Validation result + */ +function validateAuthorizationSession(session) { + return validate('api-authorization#/definitions/authorizationSession', session); +} + +/** + * Validate Credential + * @param {object} credential - Credential object to validate + * @returns {object} - Validation result + */ +function validateCredential(credential) { + return validate('api-credentials#/definitions/credential', credential); +} + +/** + * Validate List Credentials Response + * @param {object} response - List credentials response object to validate + * @returns {object} - Validation result + */ +function validateListCredentialsResponse(response) { + return validate('api-credentials#/definitions/listCredentialsResponse', response); +} + +/** + * Validate Get Credential Response + * @param {object} response - Get credential response object to validate + * @returns {object} - Validation result + */ +function validateGetCredentialResponse(response) { + return validate('api-credentials#/definitions/getCredentialResponse', response); +} + +/** + * Validate Delete Credential Response + * @param {object} response - Delete credential response object to validate + * @returns {object} - Validation result + */ +function validateDeleteCredentialResponse(response) { + return validate('api-credentials#/definitions/deleteCredentialResponse', response); +} + +/** + * Validate Reauthorize Credential Request + * @param {object} request - Reauthorize credential request object to validate + * @returns {object} - Validation result + */ +function validateReauthorizeCredentialRequest(request) { + return validate('api-credentials#/definitions/reauthorizeCredentialRequest', request); +} + +/** + * Validate Reauthorize Credential Response + * @param {object} response - Reauthorize credential response object to validate + * @returns {object} - Validation result + */ +function validateReauthorizeCredentialResponse(response) { + return validate('api-credentials#/definitions/reauthorizeCredentialResponse', response); +} + +/** + * Validate Proxy Request + * @param {object} request - Proxy request object to validate + * @returns {object} - Validation result + */ +function validateProxyRequest(request) { + return validate('api-proxy#/definitions/proxyRequest', request); +} + +/** + * Validate Proxy Response + * @param {object} response - Proxy response object to validate + * @returns {object} - Validation result + */ +function validateProxyResponse(response) { + return validate('api-proxy#/definitions/proxyResponseUnion', response); +} + +/** + * Validate Entity + * @param {object} entity - Entity object to validate + * @returns {object} - Validation result + */ +function validateEntity(entity) { + return validate('api-entities#/definitions/entity', entity); +} + +/** + * Validate List Entities Response + * @param {object} response - List entities response object to validate + * @returns {object} - Validation result + */ +function validateListEntitiesResponse(response) { + return validate('api-entities#/definitions/listEntitiesResponse', response); +} + +/** + * Validate Create Entity Request + * @param {object} request - Create entity request object to validate + * @returns {object} - Validation result + */ +function validateCreateEntityRequest(request) { + return validate('api-entities#/definitions/createEntityRequest', request); +} + +/** + * Validate Create Entity Response + * @param {object} response - Create entity response object to validate + * @returns {object} - Validation result + */ +function validateCreateEntityResponse(response) { + return validate('api-entities#/definitions/createEntityResponse', response); +} + +/** + * Validate Entity Type + * @param {object} entityType - Entity type object to validate + * @returns {object} - Validation result + */ +function validateEntityType(entityType) { + return validate('api-entities#/definitions/entityType', entityType); +} + +/** + * Validate List Entity Types Response + * @param {object} response - List entity types response object to validate + * @returns {object} - Validation result + */ +function validateListEntityTypesResponse(response) { + return validate('api-entities#/definitions/listEntityTypesResponse', response); +} + +/** + * Validate Get Entity Type Response + * @param {object} response - Get entity type response object to validate + * @returns {object} - Validation result + */ +function validateGetEntityTypeResponse(response) { + return validate('api-entities#/definitions/getEntityTypeResponse', response); +} + +/** + * Validate Reauthorize Entity Request + * @param {object} request - Reauthorize entity request object to validate + * @returns {object} - Validation result + */ +function validateReauthorizeEntityRequest(request) { + return validate('api-entities#/definitions/reauthorizeEntityRequest', request); +} + +/** + * Validate Reauthorize Entity Response + * @param {object} response - Reauthorize entity response object to validate + * @returns {object} - Validation result + */ +function validateReauthorizeEntityResponse(response) { + return validate('api-entities#/definitions/reauthorizeEntityResponse', response); +} + /** * Get all available schemas * @returns {object} - Object containing all loaded schemas @@ -158,6 +354,7 @@ function formatErrors(errors) { } module.exports = { + // Validation functions validate, validateAppDefinition, validateIntegrationDefinition, @@ -165,9 +362,39 @@ module.exports = { validateServerlessConfig, validateEnvironmentConfig, validateCoreModels, + validateAuthorizationRequirements, + validateAuthorizationRequest, + validateAuthorizationResponse, + validateAuthorizationSession, + validateCredential, + validateListCredentialsResponse, + validateGetCredentialResponse, + validateDeleteCredentialResponse, + validateReauthorizeCredentialRequest, + validateReauthorizeCredentialResponse, + validateProxyRequest, + validateProxyResponse, + validateEntity, + validateListEntitiesResponse, + validateCreateEntityRequest, + validateCreateEntityResponse, + validateEntityType, + validateListEntityTypesResponse, + validateGetEntityTypeResponse, + validateReauthorizeEntityRequest, + validateReauthorizeEntityResponse, getSchemas, getSchema, formatErrors, schemas, - ajv + ajv, + + // Middleware exports + middleware: schemaValidationMiddleware, + validateBody: schemaValidationMiddleware.validateBody, + validateQuery: schemaValidationMiddleware.validateQuery, + validateParams: schemaValidationMiddleware.validateParams, + validateResponse: schemaValidationMiddleware.validateResponse, + SchemaRefs: schemaValidationMiddleware.SchemaRefs, + SchemaValidationError: schemaValidationMiddleware.SchemaValidationError }; \ No newline at end of file diff --git a/packages/schemas/middleware/__tests__/schema-validation.test.js b/packages/schemas/middleware/__tests__/schema-validation.test.js new file mode 100644 index 000000000..a83b47042 --- /dev/null +++ b/packages/schemas/middleware/__tests__/schema-validation.test.js @@ -0,0 +1,508 @@ +/** + * Tests for schema validation middleware + */ + +const { + validateBody, + validateQuery, + validateParams, + validateResponse, + validate, + validateData, + SchemaRefs, + SchemaValidationError, + formatValidationErrors +} = require('../schema-validation'); + +// Mock Express request/response/next +function createMockReq(overrides = {}) { + return { + body: {}, + query: {}, + params: {}, + method: 'GET', + path: '/test', + ...overrides + }; +} + +function createMockRes() { + const res = { + statusCode: 200, + _data: null, + status(code) { + this.statusCode = code; + return this; + }, + json(data) { + this._data = data; + return this; + } + }; + return res; +} + +function createMockNext() { + const next = jest.fn(); + return next; +} + +describe('Schema Validation Middleware', () => { + describe('SchemaValidationError', () => { + it('should create error with correct properties', () => { + const errors = [{ instancePath: '/name', message: 'must be string' }]; + const error = new SchemaValidationError('Test error', errors, 'body'); + + expect(error.name).toBe('SchemaValidationError'); + expect(error.message).toBe('Test error'); + expect(error.errors).toBe(errors); + expect(error.location).toBe('body'); + expect(error.statusCode).toBe(400); + }); + + it('should have 500 status for response validation', () => { + const error = new SchemaValidationError('Response error', [], 'response'); + expect(error.statusCode).toBe(500); + }); + + it('should serialize to JSON correctly', () => { + const errors = [{ instancePath: '/id', message: 'must be string', params: { type: 'string' } }]; + const error = new SchemaValidationError('Validation failed', errors, 'body'); + const json = error.toJSON(); + + expect(json.error).toBe('ValidationError'); + expect(json.location).toBe('body'); + expect(json.details).toHaveLength(1); + expect(json.details[0].path).toBe('/id'); + }); + }); + + describe('formatValidationErrors', () => { + it('should format errors into readable string', () => { + const errors = [ + { instancePath: '/name', message: 'must be string' }, + { instancePath: '/age', message: 'must be integer' } + ]; + const formatted = formatValidationErrors(errors); + + expect(formatted).toContain('/name: must be string'); + expect(formatted).toContain('/age: must be integer'); + }); + + it('should include allowed values when present', () => { + const errors = [{ + instancePath: '/status', + message: 'must be equal to one of the allowed values', + params: { allowedValues: ['active', 'inactive'] } + }]; + const formatted = formatValidationErrors(errors); + + expect(formatted).toContain('allowed: active, inactive'); + }); + + it('should handle empty errors array', () => { + expect(formatValidationErrors([])).toBe('Unknown validation error'); + expect(formatValidationErrors(null)).toBe('Unknown validation error'); + }); + }); + + describe('validateBody', () => { + it('should pass valid entity body', () => { + const middleware = validateBody(SchemaRefs.entity); + const req = createMockReq({ + body: { + id: '507f1f77bcf86cd799439011', + type: 'hubspot', + credentialId: '507f1f77bcf86cd799439012', + userId: '507f1f77bcf86cd799439013', + externalId: 'contact_123' + } + }); + const res = createMockRes(); + const next = createMockNext(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + }); + + it('should reject invalid entity body in strict mode', () => { + const middleware = validateBody(SchemaRefs.entity); + const req = createMockReq({ + body: { + id: 123, // Should be string + type: 'hubspot' + } + }); + const res = createMockRes(); + const next = createMockNext(); + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(400); + expect(res._data.error).toBe('ValidationError'); + }); + + it('should allow invalid body in non-strict mode', () => { + const middleware = validateBody(SchemaRefs.entity, { strict: false }); + const req = createMockReq({ + body: { + id: 123 // Invalid + } + }); + const res = createMockRes(); + const next = createMockNext(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.validationErrors).toBeDefined(); + expect(req.validationErrors.body).toBeDefined(); + }); + }); + + describe('validateQuery', () => { + it('should pass valid query parameters', () => { + const middleware = validateQuery(SchemaRefs.createEntityRequest); + const req = createMockReq({ + query: { + entityType: 'hubspot', + data: { credential_id: 'cred123' } + } + }); + const res = createMockRes(); + const next = createMockNext(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + }); + + describe('validateResponse', () => { + it('should allow valid response', () => { + const middleware = validateResponse(SchemaRefs.listEntitiesResponse, { strict: false }); + const req = createMockReq(); + const res = createMockRes(); + const next = createMockNext(); + + middleware(req, res, next); + + // Call json with valid data + res.json({ + entities: [{ + id: '507f1f77bcf86cd799439011', + type: 'hubspot', + credentialId: '507f1f77bcf86cd799439012', + userId: '507f1f77bcf86cd799439013', + externalId: 'contact_123' + }] + }); + + expect(res._data.entities).toHaveLength(1); + }); + + it('should log errors for invalid response in non-strict mode', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const middleware = validateResponse(SchemaRefs.listEntitiesResponse, { + strict: false, + logErrors: true + }); + const req = createMockReq(); + const res = createMockRes(); + const next = createMockNext(); + + middleware(req, res, next); + + // Call json with invalid data + res.json({ + entities: 'not an array' // Invalid + }); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('validate (combined)', () => { + it('should create multiple middlewares from config', () => { + const middlewares = validate({ + body: SchemaRefs.createEntityRequest, + response: SchemaRefs.createEntityResponse + }); + + expect(middlewares).toHaveLength(2); + expect(typeof middlewares[0]).toBe('function'); + expect(typeof middlewares[1]).toBe('function'); + }); + + it('should create empty array for empty config', () => { + const middlewares = validate({}); + expect(middlewares).toHaveLength(0); + }); + }); + + describe('validateData (direct validation)', () => { + it('should validate entity data', () => { + const result = validateData('entity', { + id: '507f1f77bcf86cd799439011', + type: 'hubspot', + credentialId: '507f1f77bcf86cd799439012', + userId: '507f1f77bcf86cd799439013', + externalId: 'contact_123' + }); + + expect(result.valid).toBe(true); + expect(result.errors).toBeNull(); + }); + + it('should return errors for invalid data', () => { + const result = validateData('entity', { + id: 123 // Should be string + }); + + expect(result.valid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.formatted).toBeDefined(); + }); + + it('should throw for unknown schema name', () => { + expect(() => validateData('unknown_schema', {})).toThrow('Unknown schema name'); + }); + }); + + describe('SchemaRefs', () => { + it('should have all expected entity refs', () => { + expect(SchemaRefs.entity).toBe('api-entities#/definitions/entity'); + expect(SchemaRefs.listEntitiesResponse).toBe('api-entities#/definitions/listEntitiesResponse'); + expect(SchemaRefs.createEntityRequest).toBe('api-entities#/definitions/createEntityRequest'); + expect(SchemaRefs.createEntityResponse).toBe('api-entities#/definitions/createEntityResponse'); + expect(SchemaRefs.entityType).toBe('api-entities#/definitions/entityType'); + expect(SchemaRefs.reauthorizeEntityRequest).toBe('api-entities#/definitions/reauthorizeEntityRequest'); + }); + + it('should have all expected credential refs', () => { + expect(SchemaRefs.credential).toBe('api-credentials#/definitions/credential'); + expect(SchemaRefs.listCredentialsResponse).toBe('api-credentials#/definitions/listCredentialsResponse'); + expect(SchemaRefs.reauthorizeCredentialRequest).toBe('api-credentials#/definitions/reauthorizeCredentialRequest'); + }); + + it('should have all expected proxy refs', () => { + expect(SchemaRefs.proxyRequest).toBe('api-proxy#/definitions/proxyRequest'); + expect(SchemaRefs.proxyResponse).toBe('api-proxy#/definitions/proxyResponse'); + expect(SchemaRefs.proxyResponseUnion).toBe('api-proxy#/definitions/proxyResponseUnion'); + }); + + it('should have all expected authorization refs', () => { + expect(SchemaRefs.authorizationRequirements).toBe('api-authorization#/definitions/authorizationRequirements'); + expect(SchemaRefs.authorizationRequest).toBe('api-authorization#/definitions/authorizationRequest'); + expect(SchemaRefs.authorizationResponse).toBe('api-authorization#/definitions/authorizationResponse'); + }); + }); +}); + +describe('API Response Validation', () => { + describe('Entities API', () => { + it('should validate listEntitiesResponse', () => { + const result = validateData('listEntitiesResponse', { + entities: [{ + id: '507f1f77bcf86cd799439011', + type: 'hubspot', + credentialId: '507f1f77bcf86cd799439012', + userId: '507f1f77bcf86cd799439013', + externalId: 'contact_123', + name: 'Test Entity', + authIsValid: true, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z' + }] + }); + + expect(result.valid).toBe(true); + }); + + it('should validate createEntityRequest', () => { + const result = validateData('createEntityRequest', { + entityType: 'hubspot', + data: { + credential_id: '507f1f77bcf86cd799439012' + } + }); + + expect(result.valid).toBe(true); + }); + + it('should validate reauthorizeEntityResponse with success', () => { + const result = validateData('reauthorizeEntityResponse', { + success: true, + credential_id: '507f1f77bcf86cd799439012', + entity_id: '507f1f77bcf86cd799439011', + authIsValid: true + }); + + expect(result.valid).toBe(true); + }); + + it('should validate reauthorizeEntityResponse with next step', () => { + const result = validateData('reauthorizeEntityResponse', { + step: 2, + totalSteps: 3, + sessionId: 'session_123', + requirements: { + type: 'form', + fields: ['workspace_id'] + }, + message: 'Please select your workspace' + }); + + expect(result.valid).toBe(true); + }); + }); + + describe('Credentials API', () => { + it('should validate credential', () => { + const result = validateData('credential', { + id: '507f1f77bcf86cd799439012', + type: 'hubspot', + userId: '507f1f77bcf86cd799439013', + externalId: 'hub_123', + authIsValid: true, + entityCount: 2, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z' + }); + + expect(result.valid).toBe(true); + }); + + it('should validate listCredentialsResponse', () => { + const result = validateData('listCredentialsResponse', { + credentials: [{ + id: '507f1f77bcf86cd799439012', + type: 'hubspot', + userId: '507f1f77bcf86cd799439013', + externalId: 'hub_123', + authIsValid: true + }] + }); + + expect(result.valid).toBe(true); + }); + + it('should validate reauthorizeCredentialRequest', () => { + const result = validateData('reauthorizeCredentialRequest', { + data: { + code: 'auth_code_123', + state: 'state_123' + } + }); + + expect(result.valid).toBe(true); + }); + }); + + describe('Proxy API', () => { + it('should validate proxyRequest', () => { + const result = validateData('proxyRequest', { + method: 'GET', + path: '/api/v1/contacts' + }); + + expect(result.valid).toBe(true); + }); + + it('should validate proxyRequest with body', () => { + const result = validateData('proxyRequest', { + method: 'POST', + path: '/api/v1/contacts', + body: { + name: 'Test Contact', + email: 'test@example.com' + } + }); + + expect(result.valid).toBe(true); + }); + + it('should validate proxyRequest with headers and query', () => { + const result = validateData('proxyRequest', { + method: 'GET', + path: '/api/v1/contacts', + headers: { + 'Accept': 'application/json' + }, + query: { + limit: '10' + } + }); + + expect(result.valid).toBe(true); + }); + }); + + describe('Authorization API', () => { + it('should validate authorizationRequirements', () => { + const result = validateData('authorizationRequirements', { + type: 'oauth2', + step: 1, + totalSteps: 1, + isMultiStep: false, + data: { + url: 'https://auth.example.com/oauth/authorize', + scopes: ['read', 'write'] + } + }); + + expect(result.valid).toBe(true); + }); + + it('should validate authorizationRequest', () => { + const result = validateData('authorizationRequest', { + entityType: 'hubspot', + data: { + code: 'auth_code_123', + state: 'state_123' + } + }); + + expect(result.valid).toBe(true); + }); + + it('should validate authorizationResponse with success', () => { + const result = validateData('authorizationResponse', { + entity_id: '507f1f77bcf86cd799439011', + credential_id: '507f1f77bcf86cd799439012', + type: 'hubspot', + display: 'HubSpot Account' + }); + + expect(result.valid).toBe(true); + }); + + it('should validate authorizationResponse with next step', () => { + const result = validateData('authorizationResponse', { + nextStep: 2, + sessionId: 'session_123', + requirements: { + type: 'form', + step: 2, + totalSteps: 2, + isMultiStep: true, + data: { + jsonSchema: { + type: 'object', + properties: { + workspace_id: { type: 'string' } + } + } + } + }, + message: 'Please select your workspace' + }); + + expect(result.valid).toBe(true); + }); + }); +}); diff --git a/packages/schemas/middleware/schema-validation.js b/packages/schemas/middleware/schema-validation.js new file mode 100644 index 000000000..84e7cb92b --- /dev/null +++ b/packages/schemas/middleware/schema-validation.js @@ -0,0 +1,388 @@ +/** + * Schema Validation Middleware for Express + * + * Provides request and response validation against JSON schemas using AJV. + * Like TypeScript for APIs - enforces contracts at runtime. + */ + +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); +const fs = require('fs'); +const path = require('path'); + +// Initialize AJV with formats +const ajv = new Ajv({ + allErrors: true, + verbose: true, + strict: false + // Note: coerceTypes disabled to avoid conflicts with oneOf schemas +}); +addFormats(ajv); + +// Load all schemas +const schemaDir = path.join(__dirname, '..', 'schemas'); +const schemaFiles = [ + 'api-entities.schema.json', + 'api-credentials.schema.json', + 'api-proxy.schema.json', + 'api-authorization.schema.json' +]; + +schemaFiles.forEach(file => { + const schemaPath = path.join(schemaDir, file); + if (fs.existsSync(schemaPath)) { + const schemaContent = JSON.parse(fs.readFileSync(schemaPath, 'utf8')); + const schemaName = file.replace('.schema.json', ''); + ajv.addSchema(schemaContent, schemaName); + } +}); + +/** + * Validation error class for schema validation failures + */ +class SchemaValidationError extends Error { + constructor(message, errors, location) { + super(message); + this.name = 'SchemaValidationError'; + this.errors = errors; + this.location = location; // 'body', 'query', 'params', 'response' + this.statusCode = location === 'response' ? 500 : 400; + } + + toJSON() { + return { + error: 'ValidationError', + message: this.message, + location: this.location, + details: this.errors.map(err => ({ + path: err.instancePath || 'root', + message: err.message, + params: err.params + })) + }; + } +} + +/** + * Format AJV errors into human-readable messages + * @param {Array} errors - AJV validation errors + * @returns {string} - Formatted error message + */ +function formatValidationErrors(errors) { + if (!errors || errors.length === 0) { + return 'Unknown validation error'; + } + + return errors.map(error => { + const path = error.instancePath || 'root'; + const message = error.message; + const allowedValues = error.params?.allowedValues + ? ` (allowed: ${error.params.allowedValues.join(', ')})` + : ''; + return `${path}: ${message}${allowedValues}`; + }).join('; '); +} + +/** + * Get a compiled validator for a schema reference + * @param {string} schemaRef - Schema reference (e.g., 'api-entities#/definitions/entity') + * @returns {Function} - Compiled AJV validator + */ +function getValidator(schemaRef) { + const validator = ajv.getSchema(schemaRef); + if (!validator) { + throw new Error(`Schema not found: ${schemaRef}`); + } + return validator; +} + +/** + * Validate request body against a schema + * @param {string} schemaRef - Schema reference + * @param {Object} options - Validation options + * @param {boolean} options.strict - If true, throws on validation failure (default: true) + * @param {boolean} options.coerceTypes - If true, coerces types (default: false for body) + * @returns {Function} - Express middleware + */ +function validateBody(schemaRef, options = {}) { + const { strict = true } = options; + + return (req, res, next) => { + try { + const validator = getValidator(schemaRef); + const valid = validator(req.body); + + if (!valid) { + const error = new SchemaValidationError( + `Request body validation failed: ${formatValidationErrors(validator.errors)}`, + validator.errors, + 'body' + ); + + if (strict) { + return res.status(400).json(error.toJSON()); + } + + // Non-strict: attach errors but continue + req.validationErrors = req.validationErrors || {}; + req.validationErrors.body = validator.errors; + } + + next(); + } catch (err) { + next(err); + } + }; +} + +/** + * Validate query parameters against a schema + * @param {string} schemaRef - Schema reference + * @param {Object} options - Validation options + * @returns {Function} - Express middleware + */ +function validateQuery(schemaRef, options = {}) { + const { strict = true } = options; + + return (req, res, next) => { + try { + const validator = getValidator(schemaRef); + const valid = validator(req.query); + + if (!valid) { + const error = new SchemaValidationError( + `Query parameter validation failed: ${formatValidationErrors(validator.errors)}`, + validator.errors, + 'query' + ); + + if (strict) { + return res.status(400).json(error.toJSON()); + } + + req.validationErrors = req.validationErrors || {}; + req.validationErrors.query = validator.errors; + } + + next(); + } catch (err) { + next(err); + } + }; +} + +/** + * Validate route parameters against a schema + * @param {string} schemaRef - Schema reference + * @param {Object} options - Validation options + * @returns {Function} - Express middleware + */ +function validateParams(schemaRef, options = {}) { + const { strict = true } = options; + + return (req, res, next) => { + try { + const validator = getValidator(schemaRef); + const valid = validator(req.params); + + if (!valid) { + const error = new SchemaValidationError( + `Route parameter validation failed: ${formatValidationErrors(validator.errors)}`, + validator.errors, + 'params' + ); + + if (strict) { + return res.status(400).json(error.toJSON()); + } + + req.validationErrors = req.validationErrors || {}; + req.validationErrors.params = validator.errors; + } + + next(); + } catch (err) { + next(err); + } + }; +} + +/** + * Validate response body against a schema (for development/testing) + * Wraps res.json() to validate response data + * @param {string} schemaRef - Schema reference + * @param {Object} options - Validation options + * @returns {Function} - Express middleware + */ +function validateResponse(schemaRef, options = {}) { + const { strict = false, logErrors = true } = options; + + return (req, res, next) => { + const originalJson = res.json.bind(res); + + res.json = function(data) { + try { + const validator = getValidator(schemaRef); + const valid = validator(data); + + if (!valid) { + const errorMessage = formatValidationErrors(validator.errors); + + if (logErrors) { + console.error(`Response validation failed for ${req.method} ${req.path}:`, errorMessage); + } + + if (strict) { + const error = new SchemaValidationError( + `Response validation failed: ${errorMessage}`, + validator.errors, + 'response' + ); + return originalJson.call(res.status(500), error.toJSON()); + } + + // Non-strict: log but continue + // Optionally add validation metadata to response + if (options.includeMetadata) { + data._validation = { + valid: false, + errors: validator.errors + }; + } + } + } catch (err) { + if (logErrors) { + console.error('Response validation error:', err.message); + } + } + + return originalJson.call(res, data); + }; + + next(); + }; +} + +/** + * Combined validation middleware - validates request and response + * @param {Object} config - Validation configuration + * @param {string} config.body - Schema ref for body validation + * @param {string} config.query - Schema ref for query validation + * @param {string} config.params - Schema ref for params validation + * @param {string} config.response - Schema ref for response validation + * @param {Object} config.options - Validation options + * @returns {Array} - Array of Express middleware + */ +function validate(config = {}) { + const middlewares = []; + const { options = {} } = config; + + if (config.body) { + middlewares.push(validateBody(config.body, options)); + } + + if (config.query) { + middlewares.push(validateQuery(config.query, options)); + } + + if (config.params) { + middlewares.push(validateParams(config.params, options)); + } + + if (config.response) { + middlewares.push(validateResponse(config.response, options)); + } + + return middlewares; +} + +/** + * Schema reference helpers for common API schemas + */ +const SchemaRefs = { + // Entities + entity: 'api-entities#/definitions/entity', + listEntitiesResponse: 'api-entities#/definitions/listEntitiesResponse', + createEntityRequest: 'api-entities#/definitions/createEntityRequest', + createEntityResponse: 'api-entities#/definitions/createEntityResponse', + entityType: 'api-entities#/definitions/entityType', + listEntityTypesResponse: 'api-entities#/definitions/listEntityTypesResponse', + getEntityTypeResponse: 'api-entities#/definitions/getEntityTypeResponse', + reauthorizeEntityRequest: 'api-entities#/definitions/reauthorizeEntityRequest', + reauthorizeEntityResponse: 'api-entities#/definitions/reauthorizeEntityResponse', + + // Credentials + credential: 'api-credentials#/definitions/credential', + listCredentialsResponse: 'api-credentials#/definitions/listCredentialsResponse', + getCredentialResponse: 'api-credentials#/definitions/getCredentialResponse', + deleteCredentialResponse: 'api-credentials#/definitions/deleteCredentialResponse', + reauthorizeCredentialRequest: 'api-credentials#/definitions/reauthorizeCredentialRequest', + reauthorizeCredentialResponse: 'api-credentials#/definitions/reauthorizeCredentialResponse', + + // Proxy + proxyRequest: 'api-proxy#/definitions/proxyRequest', + proxyResponse: 'api-proxy#/definitions/proxyResponse', + proxyErrorResponse: 'api-proxy#/definitions/proxyErrorResponse', + proxyResponseUnion: 'api-proxy#/definitions/proxyResponseUnion', + + // Authorization + authorizationRequirements: 'api-authorization#/definitions/authorizationRequirements', + authorizationRequest: 'api-authorization#/definitions/authorizationRequest', + authorizationResponse: 'api-authorization#/definitions/authorizationResponse', + authorizationSession: 'api-authorization#/definitions/authorizationSession', + getEntityTypeRequirementsResponse: 'api-authorization#/definitions/getEntityTypeRequirementsResponse' +}; + +/** + * Precompiled validators for performance + */ +const Validators = {}; + +// Lazy-load validators on first use +function getCompiledValidator(name) { + if (!Validators[name]) { + const ref = SchemaRefs[name]; + if (!ref) { + throw new Error(`Unknown schema name: ${name}`); + } + Validators[name] = getValidator(ref); + } + return Validators[name]; +} + +/** + * Direct validation functions (not middleware) + * Useful for testing and manual validation + */ +function validateData(schemaName, data) { + const validator = getCompiledValidator(schemaName); + const valid = validator(data); + return { + valid, + errors: valid ? null : validator.errors, + formatted: valid ? null : formatValidationErrors(validator.errors) + }; +} + +module.exports = { + // Middleware + validateBody, + validateQuery, + validateParams, + validateResponse, + validate, + + // Direct validation + validateData, + getValidator, + formatValidationErrors, + + // Schema references + SchemaRefs, + + // Error class + SchemaValidationError, + + // AJV instance (for advanced use) + ajv +}; diff --git a/packages/schemas/mocks/README.md b/packages/schemas/mocks/README.md new file mode 100644 index 000000000..644270b29 --- /dev/null +++ b/packages/schemas/mocks/README.md @@ -0,0 +1,280 @@ +# Authorization Mocks + +Schema-compliant mock data generators for testing authentication and authorization flows across the Frigg Framework. + +## Purpose + +These mocks ensure consistency across all Frigg packages: +- `@friggframework/core` - Backend authorization logic +- `@friggframework/ui` - Frontend integration components +- `@friggframework/devtools/management-ui` - Developer tooling + +All mock data is **validated against canonical JSON schemas** to guarantee accuracy. + +## Installation + +```bash +npm install @friggframework/schemas +``` + +## Usage + +### Basic Examples + +```javascript +const { + createOAuth2Requirements, + createFormRequirements, + createNagarisOTPFlowMock, + createAuthorizationSuccess, +} = require('@friggframework/schemas/mocks/authorization-mocks'); + +// OAuth2 flow +const hubspotAuth = createOAuth2Requirements('hubspot'); +// { +// type: 'oauth2', +// step: 1, +// totalSteps: 1, +// isMultiStep: false, +// data: { +// url: 'https://auth.hubspot.com/oauth/authorize?...', +// scopes: ['read', 'write'] +// } +// } + +// Form-based auth +const apiKeyAuth = createFormRequirements('custom-api', { + fields: ['api_key'] +}); +// { +// type: 'form', +// data: { +// jsonSchema: { ... }, +// uiSchema: { ... } +// } +// } + +// Multi-step OTP flow (like Nagaris) +const nagarisFlow = createNagarisOTPFlowMock('user-123'); +const step1Reqs = nagarisFlow.getStep1Requirements(); // Email form +const step1Response = nagarisFlow.submitStep1({ email: 'test@example.com' }); // OTP sent +const step2Response = nagarisFlow.submitStep2({ otp: '123456' }); // Success +``` + +### In Tests + +#### Core Package Tests + +```javascript +// packages/core/__tests__/authorization-flow.test.js +const { createNagarisOTPFlowMock } = require('@friggframework/schemas/mocks/authorization-mocks'); +const { validateAuthorizationSession } = require('@friggframework/schemas'); + +test('processes multi-step auth', async () => { + const mockFlow = createNagarisOTPFlowMock('user-123'); + const session = mockFlow.session; + + // Validate before storing + const validation = validateAuthorizationSession(session); + expect(validation.valid).toBe(true); + + // Use in repository test + await authSessionRepository.create(session); +}); +``` + +#### UI Package Tests + +```javascript +// packages/ui/__tests__/AuthorizationWizard.test.jsx +const { createFormRequirements } = require('@friggframework/schemas/mocks/authorization-mocks'); + +test('renders multi-step OTP form', async () => { + const mockApi = { + getAuthorizationRequirements: jest.fn().mockResolvedValue( + createFormRequirements('nagaris', { + fields: ['email'], + isMultiStep: true, + step: 1, + totalSteps: 2 + }) + ) + }; + + render(); + // Test form rendering... +}); +``` + +#### Management UI Tests + +```javascript +// packages/devtools/management-ui/__tests__/TestingZone.test.jsx +const { createOAuth2Requirements } = require('@friggframework/schemas/mocks/authorization-mocks'); + +test('displays OAuth authorization URL', () => { + const mockData = createOAuth2Requirements('hubspot'); + + render(); + expect(screen.getByText(/hubspot.com\/oauth/)).toBeInTheDocument(); +}); +``` + +## API Reference + +### OAuth2 Flows + +#### `createOAuth2Requirements(moduleType, options)` + +Create OAuth2 authorization requirements. + +**Parameters:** +- `moduleType` (string): Module name (e.g., 'hubspot', 'salesforce') +- `options.scopes` (array): OAuth scopes (default: ['read', 'write']) +- `options.isMultiStep` (boolean): Multi-step flow (default: false) +- `options.step` (number): Current step (default: 1) +- `options.totalSteps` (number): Total steps (default: 1) +- `options.sessionId` (string): Session ID for multi-step + +**Returns:** Authorization requirements object (validated against schema) + +#### `createOAuth2FlowMock(moduleType, userId)` + +Create complete OAuth2 flow with methods for each step. + +**Returns:** Object with `getRequirements()` and `handleCallback()` methods + +### Form-Based Flows + +#### `createFormRequirements(moduleType, options)` + +Create form-based authorization requirements with JSON Schema. + +**Parameters:** +- `moduleType` (string): Module name +- `options.fields` (array): Field names (email, password, api_key, otp, etc.) +- `options.isMultiStep` (boolean): Multi-step flow +- `options.step` (number): Current step +- `options.totalSteps` (number): Total steps +- `options.sessionId` (string): Session ID + +**Returns:** Form requirements with jsonSchema and uiSchema + +**Supported Fields:** +- `email` - Email input with validation +- `password` - Password input (min 6 chars) +- `api_key` - API key text input +- `otp` - 6-digit OTP input with pattern validation +- Custom fields - Generic text inputs + +### Multi-Step OTP Flows + +#### `createOTPMultiStepFlow(moduleType)` + +Create multi-step flow structure (email → OTP). + +**Returns:** Object with `step1` and `step2(sessionId)` properties + +#### `createNagarisOTPFlowMock(userId)` + +Create complete Nagaris-style OTP flow with all steps. + +**Returns:** Object with methods: +- `getStep1Requirements()` - Get email form +- `submitStep1(emailData)` - Submit email, get OTP prompt +- `submitStep2(otpData)` - Submit OTP, get success +- `session` - Authorization session object +- `sessionId` - Session identifier +- `email` - Test email address + +### Response Builders + +#### `createAuthorizationSuccess(moduleType, options)` + +Create successful authorization response. + +**Parameters:** +- `moduleType` (string): Module name +- `options.entityId` (string): Entity ID (auto-generated if not provided) +- `options.credentialId` (string): Credential ID (auto-generated) +- `options.display` (string): Display name + +**Returns:** Success response object + +#### `createAuthorizationNextStep(nextStep, requirements, options)` + +Create next step response for multi-step flows. + +**Parameters:** +- `nextStep` (number): Next step number +- `requirements` (object): Requirements for next step +- `options.sessionId` (string): Session ID (auto-generated) +- `options.message` (string): User message + +**Returns:** Next step response object + +### Session Management + +#### `createAuthorizationSession(userId, entityType, options)` + +Create authorization session database object. + +**Parameters:** +- `userId` (string): User ID +- `entityType` (string): Module type +- `options.currentStep` (number): Current step (default: 1) +- `options.maxSteps` (number): Total steps (default: 2) +- `options.stepData` (object): Data from previous steps +- `options.expiresInMinutes` (number): Expiration time (default: 15) +- `options.completed` (boolean): Completion status + +**Returns:** Session object ready for database storage + +#### `generateSessionId()` + +Generate a UUID v4 session ID. + +**Returns:** UUID string + +## Validation + +All mock data is validated against schemas in `packages/schemas/schemas/api-authorization.schema.json`. + +```javascript +const { validateAuthorizationRequirements } = require('@friggframework/schemas'); + +const mockData = createFormRequirements('nagaris', { fields: ['email'] }); +const result = validateAuthorizationRequirements(mockData); + +if (result.valid) { + console.log('✅ Mock data is schema-compliant'); +} else { + console.error('❌ Validation errors:', result.errors); +} +``` + +## Testing + +Run the mock validation tests: + +```bash +cd packages/schemas +npm test mocks/__tests__/authorization-mocks.test.js +``` + +All tests validate that mocks are schema-compliant and work across packages. + +## Contributing + +When adding new authorization types: + +1. Add schema definition to `api-authorization.schema.json` +2. Add mock generator to `authorization-mocks.js` +3. Add validation tests to `__tests__/authorization-mocks.test.js` +4. Update this README with usage examples + +## Related + +- [API Authorization Schema](../schemas/api-authorization.schema.json) +- [Core Authorization Use Cases](../../core/modules/use-cases/) +- [UI Authorization Components](../../ui/lib/integration/presentation/components/) diff --git a/packages/schemas/mocks/__tests__/authorization-mocks.test.js b/packages/schemas/mocks/__tests__/authorization-mocks.test.js new file mode 100644 index 000000000..609b04624 --- /dev/null +++ b/packages/schemas/mocks/__tests__/authorization-mocks.test.js @@ -0,0 +1,311 @@ +/** + * @file Authorization Mocks Tests + * @description Validates that all mock data generators produce schema-compliant data + */ + +const { + validateAuthorizationRequirements, + validateAuthorizationResponse, + validateAuthorizationSession, +} = require('../../index'); + +const { + createOAuth2Requirements, + createFormRequirements, + createOTPMultiStepFlow, + createAuthorizationSuccess, + createAuthorizationNextStep, + createAuthorizationSession, + createNagarisOTPFlowMock, + createOAuth2FlowMock, +} = require('../authorization-mocks'); + +describe('Authorization Mocks Schema Validation', () => { + describe('createOAuth2Requirements', () => { + it('should create valid OAuth2 requirements', () => { + const requirements = createOAuth2Requirements('hubspot'); + const result = validateAuthorizationRequirements(requirements); + + expect(result.valid).toBe(true); + expect(result.errors).toBeNull(); + expect(requirements.type).toBe('oauth2'); + expect(requirements.data.url).toContain('hubspot'); + }); + + it('should support custom scopes', () => { + const requirements = createOAuth2Requirements('salesforce', { + scopes: ['full', 'refresh_token'], + }); + const result = validateAuthorizationRequirements(requirements); + + expect(result.valid).toBe(true); + expect(requirements.data.scopes).toEqual(['full', 'refresh_token']); + }); + + it('should support multi-step OAuth', () => { + const sessionId = 'session-123'; + const requirements = createOAuth2Requirements('hubspot', { + isMultiStep: true, + step: 2, + totalSteps: 3, + sessionId, + }); + const result = validateAuthorizationRequirements(requirements); + + expect(result.valid).toBe(true); + expect(requirements.isMultiStep).toBe(true); + expect(requirements.step).toBe(2); + expect(requirements.sessionId).toBe(sessionId); + }); + }); + + describe('createFormRequirements', () => { + it('should create valid form requirements with email/password', () => { + const requirements = createFormRequirements('custom-api', { + fields: ['email', 'password'], + }); + const result = validateAuthorizationRequirements(requirements); + + expect(result.valid).toBe(true); + expect(requirements.type).toBe('form'); + expect(requirements.data.jsonSchema.properties).toHaveProperty('email'); + expect(requirements.data.jsonSchema.properties).toHaveProperty('password'); + expect(requirements.data.jsonSchema.required).toContain('email'); + expect(requirements.data.jsonSchema.required).toContain('password'); + }); + + it('should create valid OTP field', () => { + const requirements = createFormRequirements('nagaris', { + fields: ['otp'], + }); + const result = validateAuthorizationRequirements(requirements); + + expect(result.valid).toBe(true); + expect(requirements.data.jsonSchema.properties.otp).toBeDefined(); + expect(requirements.data.jsonSchema.properties.otp.pattern).toBe('^[0-9]{6}$'); + expect(requirements.data.uiSchema.otp['ui:help']).toContain('6-digit'); + }); + + it('should support API key fields', () => { + const requirements = createFormRequirements('api-service', { + fields: ['api_key'], + }); + const result = validateAuthorizationRequirements(requirements); + + expect(result.valid).toBe(true); + expect(requirements.data.jsonSchema.properties).toHaveProperty('api_key'); + }); + }); + + describe('createOTPMultiStepFlow', () => { + it('should create valid multi-step flow', () => { + const flow = createOTPMultiStepFlow('nagaris'); + + // Validate step 1 + const step1Result = validateAuthorizationRequirements(flow.step1); + expect(step1Result.valid).toBe(true); + expect(flow.step1.step).toBe(1); + expect(flow.step1.totalSteps).toBe(2); + expect(flow.step1.isMultiStep).toBe(true); + + // Validate step 2 + const step2 = flow.step2('session-abc'); + const step2Result = validateAuthorizationRequirements(step2); + expect(step2Result.valid).toBe(true); + expect(step2.step).toBe(2); + expect(step2.sessionId).toBe('session-abc'); + }); + }); + + describe('createAuthorizationSuccess', () => { + it('should create valid success response', () => { + const success = createAuthorizationSuccess('hubspot'); + const result = validateAuthorizationResponse(success); + + expect(result.valid).toBe(true); + expect(success.entity_id).toBeDefined(); + expect(success.credential_id).toBeDefined(); + expect(success.type).toBe('hubspot'); + expect(success.display).toContain('hubspot'); + }); + + it('should support custom IDs', () => { + const success = createAuthorizationSuccess('salesforce', { + entityId: 'entity-123', + credentialId: 'cred-456', + display: 'My Salesforce Org', + }); + const result = validateAuthorizationResponse(success); + + expect(result.valid).toBe(true); + expect(success.entity_id).toBe('entity-123'); + expect(success.credential_id).toBe('cred-456'); + expect(success.display).toBe('My Salesforce Org'); + }); + }); + + describe('createAuthorizationNextStep', () => { + it('should create valid next step response', () => { + const step2Reqs = createFormRequirements('nagaris', { + fields: ['otp'], + step: 2, + totalSteps: 2, + }); + + const nextStep = createAuthorizationNextStep(2, step2Reqs); + const result = validateAuthorizationResponse(nextStep); + + expect(result.valid).toBe(true); + expect(nextStep.nextStep).toBe(2); + expect(nextStep.sessionId).toBeDefined(); + expect(nextStep.requirements).toEqual(step2Reqs); + expect(nextStep.message).toContain('step'); + }); + + it('should support custom session ID and message', () => { + const step2Reqs = createFormRequirements('nagaris', { fields: ['otp'] }); + const nextStep = createAuthorizationNextStep(2, step2Reqs, { + sessionId: 'custom-session', + message: 'OTP sent to your email', + }); + const result = validateAuthorizationResponse(nextStep); + + expect(result.valid).toBe(true); + expect(nextStep.sessionId).toBe('custom-session'); + expect(nextStep.message).toBe('OTP sent to your email'); + }); + }); + + describe('createAuthorizationSession', () => { + it('should create valid authorization session', () => { + const session = createAuthorizationSession('user-123', 'nagaris'); + const result = validateAuthorizationSession(session); + + expect(result.valid).toBe(true); + expect(session.userId).toBe('user-123'); + expect(session.entityType).toBe('nagaris'); + expect(session.sessionId).toBeDefined(); + expect(session.currentStep).toBe(1); + expect(session.maxSteps).toBe(2); + expect(session.completed).toBe(false); + }); + + it('should support custom step data and completion', () => { + const session = createAuthorizationSession('user-456', 'hubspot', { + currentStep: 2, + maxSteps: 3, + stepData: { email: 'test@example.com', domain: 'mycompany' }, + completed: true, + }); + const result = validateAuthorizationSession(session); + + expect(result.valid).toBe(true); + expect(session.currentStep).toBe(2); + expect(session.maxSteps).toBe(3); + expect(session.stepData.email).toBe('test@example.com'); + expect(session.completed).toBe(true); + }); + + it('should have valid expiration timestamp', () => { + const session = createAuthorizationSession('user-789', 'salesforce', { + expiresInMinutes: 30, + }); + + const now = new Date(); + const expiresAt = new Date(session.expiresAt); + const diffMinutes = (expiresAt - now) / (60 * 1000); + + expect(diffMinutes).toBeGreaterThanOrEqual(29); + expect(diffMinutes).toBeLessThanOrEqual(31); + }); + }); + + describe('createNagarisOTPFlowMock', () => { + it('should create complete valid Nagaris OTP flow', () => { + const flow = createNagarisOTPFlowMock('user-123'); + + // Step 1: Get requirements + const step1Reqs = flow.getStep1Requirements(); + const step1ReqsResult = validateAuthorizationRequirements(step1Reqs); + expect(step1ReqsResult.valid).toBe(true); + expect(step1Reqs.step).toBe(1); + + // Step 1: Submit + const step1Response = flow.submitStep1({ email: flow.email }); + const step1ResponseResult = validateAuthorizationResponse(step1Response); + expect(step1ResponseResult.valid).toBe(true); + expect(step1Response.nextStep).toBe(2); + expect(step1Response.sessionId).toBe(flow.sessionId); + + // Step 2: Submit + const step2Response = flow.submitStep2({ otp: '123456' }); + const step2ResponseResult = validateAuthorizationResponse(step2Response); + expect(step2ResponseResult.valid).toBe(true); + expect(step2Response.entity_id).toBeDefined(); + expect(step2Response.type).toBe('nagaris'); + + // Session + const sessionResult = validateAuthorizationSession(flow.session); + expect(sessionResult.valid).toBe(true); + expect(flow.session.userId).toBe('user-123'); + expect(flow.session.entityType).toBe('nagaris'); + }); + }); + + describe('createOAuth2FlowMock', () => { + it('should create complete valid OAuth2 flow', () => { + const flow = createOAuth2FlowMock('hubspot', 'user-456'); + + // Get requirements + const requirements = flow.getRequirements(); + const reqsResult = validateAuthorizationRequirements(requirements); + expect(reqsResult.valid).toBe(true); + expect(requirements.type).toBe('oauth2'); + expect(requirements.isMultiStep).toBe(false); + + // Handle callback + const callbackResponse = flow.handleCallback('auth-code-123', 'state-xyz'); + const callbackResult = validateAuthorizationResponse(callbackResponse); + expect(callbackResult.valid).toBe(true); + expect(callbackResponse.entity_id).toBeDefined(); + expect(callbackResponse.type).toBe('hubspot'); + + // No session for single-step OAuth + expect(flow.session).toBeNull(); + }); + }); +}); + +describe('Cross-Package Compatibility', () => { + it('should work with @friggframework/core integration tests', () => { + // This mock can be used in core integration tests + const flow = createNagarisOTPFlowMock('test-user'); + const session = flow.session; + + // Core would validate session before storing + const result = validateAuthorizationSession(session); + expect(result.valid).toBe(true); + }); + + it('should work with @friggframework/ui component tests', () => { + // This mock can be used in UI component tests + const requirements = createFormRequirements('nagaris', { + fields: ['email'], + }); + + // UI would render form based on jsonSchema + expect(requirements.data.jsonSchema.properties.email).toBeDefined(); + expect(requirements.data.uiSchema.email).toBeDefined(); + }); + + it('should work with management-ui tests', () => { + // Management UI would display auth flows + const oauthReqs = createOAuth2Requirements('hubspot'); + const formReqs = createFormRequirements('api-service', { + fields: ['api_key'], + }); + + expect(oauthReqs.type).toBe('oauth2'); + expect(formReqs.type).toBe('form'); + }); +}); diff --git a/packages/schemas/mocks/authorization-mocks.js b/packages/schemas/mocks/authorization-mocks.js new file mode 100644 index 000000000..63d0ebdce --- /dev/null +++ b/packages/schemas/mocks/authorization-mocks.js @@ -0,0 +1,339 @@ +/** + * @file Authorization Mock Data Generators + * @description Canonical mock data for authorization flows + * + * These mocks are schema-compliant and can be used across: + * - @friggframework/core tests + * - @friggframework/ui tests + * - @friggframework/devtools/management-ui tests + * - Integration tests + * + * Usage: + * ```js + * const { createOAuth2Requirements, createFormRequirements } = require('@friggframework/schemas/mocks/authorization-mocks'); + * + * const mockData = createOAuth2Requirements('hubspot'); + * ``` + */ + +const crypto = require('crypto'); + +/** + * Generate a unique session ID + */ +function generateSessionId() { + return crypto.randomUUID(); +} + +/** + * Create OAuth2 authorization requirements + * @param {string} moduleType - Module type (e.g., 'hubspot', 'salesforce') + * @param {object} options - Additional options + * @returns {object} OAuth2 requirements object + */ +function createOAuth2Requirements(moduleType, options = {}) { + const { + scopes = ['read', 'write'], + isMultiStep = false, + step = 1, + totalSteps = 1, + sessionId = null + } = options; + + const requirements = { + type: 'oauth2', + step, + totalSteps, + isMultiStep, + data: { + url: `https://auth.${moduleType}.com/oauth/authorize?client_id=abc123&redirect_uri=http://localhost:3000/callback&state=xyz`, + scopes + } + }; + + if (sessionId) { + requirements.sessionId = sessionId; + } + + return requirements; +} + +/** + * Create form-based authorization requirements + * @param {string} moduleType - Module type + * @param {object} options - Additional options + * @returns {object} Form requirements object + */ +function createFormRequirements(moduleType, options = {}) { + const { + fields = ['email', 'password'], + isMultiStep = false, + step = 1, + totalSteps = 1, + sessionId = null + } = options; + + const properties = {}; + const required = []; + + // Build JSON schema properties + fields.forEach(field => { + if (field === 'email') { + properties.email = { + type: 'string', + format: 'email', + title: 'Email Address' + }; + required.push('email'); + } else if (field === 'password') { + properties.password = { + type: 'string', + title: 'Password', + minLength: 6 + }; + required.push('password'); + } else if (field === 'api_key') { + properties.api_key = { + type: 'string', + title: 'API Key' + }; + required.push('api_key'); + } else if (field === 'otp') { + properties.otp = { + type: 'string', + title: 'One-Time Password', + pattern: '^[0-9]{6}$' + }; + required.push('otp'); + } else { + properties[field] = { + type: 'string', + title: field.charAt(0).toUpperCase() + field.slice(1) + }; + } + }); + + const requirements = { + type: 'form', + step, + totalSteps, + isMultiStep, + data: { + jsonSchema: { + title: `Connect ${moduleType}`, + description: `Enter your ${moduleType} credentials`, + type: 'object', + required, + properties + }, + uiSchema: { + email: { + 'ui:placeholder': 'your.email@company.com' + }, + password: { + 'ui:widget': 'password' + }, + otp: { + 'ui:placeholder': '123456', + 'ui:help': 'Enter the 6-digit code sent to your email' + } + } + } + }; + + if (sessionId) { + requirements.sessionId = sessionId; + } + + return requirements; +} + +/** + * Create multi-step OTP authorization flow (like Nagaris) + * @param {string} moduleType - Module type + * @returns {object} Multi-step requirements + */ +function createOTPMultiStepFlow(moduleType = 'nagaris') { + return { + step1: createFormRequirements(moduleType, { + fields: ['email'], + isMultiStep: true, + step: 1, + totalSteps: 2 + }), + step2: (sessionId) => createFormRequirements(moduleType, { + fields: ['otp'], + isMultiStep: true, + step: 2, + totalSteps: 2, + sessionId + }) + }; +} + +/** + * Create authorization success response + * @param {string} moduleType - Module type + * @param {object} options - Additional options + * @returns {object} Authorization success response + */ +function createAuthorizationSuccess(moduleType, options = {}) { + const { + entityId = crypto.randomUUID(), + credentialId = crypto.randomUUID(), + display = `My ${moduleType} Account` + } = options; + + return { + entity_id: entityId, + credential_id: credentialId, + type: moduleType, + display + }; +} + +/** + * Create authorization next step response + * @param {number} nextStep - Next step number + * @param {object} requirements - Requirements for next step + * @param {object} options - Additional options + * @returns {object} Next step response + */ +function createAuthorizationNextStep(nextStep, requirements, options = {}) { + const { + sessionId = generateSessionId(), + message = `Step ${nextStep - 1} completed. Proceed to step ${nextStep}.` + } = options; + + return { + nextStep, + sessionId, + requirements, + message + }; +} + +/** + * Create authorization session object + * @param {string} userId - User ID + * @param {string} entityType - Entity/module type + * @param {object} options - Additional options + * @returns {object} Authorization session + */ +function createAuthorizationSession(userId, entityType, options = {}) { + const { + currentStep = 1, + maxSteps = 2, + stepData = {}, + expiresInMinutes = 15, + completed = false + } = options; + + const now = new Date(); + const expiresAt = new Date(now.getTime() + expiresInMinutes * 60000); + + return { + sessionId: generateSessionId(), + userId, + entityType, + currentStep, + maxSteps, + stepData, + expiresAt: expiresAt.toISOString(), + completed, + createdAt: now.toISOString(), + updatedAt: now.toISOString() + }; +} + +/** + * Create a complete Nagaris OTP flow mock + * @param {string} userId - User ID + * @returns {object} Complete flow mock with step functions + */ +function createNagarisOTPFlowMock(userId = 'user123') { + const sessionId = generateSessionId(); + const email = 'test@example.com'; + + return { + // Step 1: Get requirements (email) + getStep1Requirements: () => createFormRequirements('nagaris', { + fields: ['email'], + isMultiStep: true, + step: 1, + totalSteps: 2 + }), + + // Step 1: Submit email, get next step + submitStep1: (emailData) => { + const step2Reqs = createFormRequirements('nagaris', { + fields: ['otp'], + isMultiStep: true, + step: 2, + totalSteps: 2, + sessionId + }); + + return createAuthorizationNextStep(2, step2Reqs, { + sessionId, + message: 'OTP sent to your email. Please check your inbox.' + }); + }, + + // Step 2: Submit OTP, get success + submitStep2: (otpData) => { + return createAuthorizationSuccess('nagaris', { + display: `Nagaris Account (${email})` + }); + }, + + // Session object + session: createAuthorizationSession(userId, 'nagaris', { + currentStep: 1, + maxSteps: 2, + stepData: { email } + }), + + // Utility + sessionId, + email + }; +} + +/** + * Create a complete OAuth2 flow mock + * @param {string} moduleType - Module type (e.g., 'hubspot') + * @param {string} userId - User ID + * @returns {object} Complete OAuth flow mock + */ +function createOAuth2FlowMock(moduleType, userId = 'user123') { + return { + // Get initial requirements + getRequirements: () => createOAuth2Requirements(moduleType), + + // OAuth callback with code + handleCallback: (code, state) => { + return createAuthorizationSuccess(moduleType, { + display: `My ${moduleType} Account` + }); + }, + + // Session (single-step OAuth doesn't need session) + session: null + }; +} + +module.exports = { + // Generators + generateSessionId, + createOAuth2Requirements, + createFormRequirements, + createOTPMultiStepFlow, + createAuthorizationSuccess, + createAuthorizationNextStep, + createAuthorizationSession, + + // Complete flow mocks + createNagarisOTPFlowMock, + createOAuth2FlowMock, +}; diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 8a4cf3b38..c51740845 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -37,6 +37,8 @@ "files": [ "schemas/", "validators/", + "mocks/", + "middleware/", "index.js" ] } \ No newline at end of file diff --git a/packages/schemas/schemas/api-authorization.schema.json b/packages/schemas/schemas/api-authorization.schema.json new file mode 100644 index 000000000..5552e9e46 --- /dev/null +++ b/packages/schemas/schemas/api-authorization.schema.json @@ -0,0 +1,302 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://friggframework.org/schemas/api-authorization.json", + "title": "Frigg Authorization API Schemas", + "description": "JSON schemas for authorization and authentication API requests and responses", + "definitions": { + "authorizationRequirements": { + "type": "object", + "description": "Authorization requirements response from GET /api/authorize", + "required": ["type", "step", "totalSteps", "isMultiStep"], + "properties": { + "type": { + "type": "string", + "enum": ["oauth2", "form", "api-key", "basic"], + "description": "Type of authentication required" + }, + "step": { + "type": "integer", + "minimum": 1, + "description": "Current step number (1-indexed)" + }, + "totalSteps": { + "type": "integer", + "minimum": 1, + "description": "Total number of steps in the auth flow" + }, + "isMultiStep": { + "type": "boolean", + "description": "Whether this is a multi-step authentication flow" + }, + "sessionId": { + "type": "string", + "description": "Session ID for multi-step flows (required after step 1)" + }, + "data": { + "type": "object", + "description": "Step-specific data (schema varies by type)", + "oneOf": [ + { "$ref": "#/definitions/oauth2Requirements" }, + { "$ref": "#/definitions/formRequirements" }, + { "$ref": "#/definitions/apiKeyRequirements" } + ] + } + } + }, + "oauth2Requirements": { + "type": "object", + "description": "OAuth2 authentication requirements", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "OAuth2 authorization URL to redirect user to" + }, + "scopes": { + "type": "array", + "items": { "type": "string" }, + "description": "Requested OAuth scopes" + } + } + }, + "formRequirements": { + "type": "object", + "description": "Form-based authentication requirements", + "required": ["jsonSchema"], + "properties": { + "jsonSchema": { + "type": "object", + "description": "JSON Schema for the form fields", + "required": ["type", "properties"], + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" }, + "type": { "const": "object" }, + "required": { + "type": "array", + "items": { "type": "string" } + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "uiSchema": { + "type": "object", + "description": "UI schema for form rendering (react-jsonschema-form format)", + "additionalProperties": true + } + } + }, + "apiKeyRequirements": { + "type": "object", + "description": "API key authentication requirements", + "required": ["fields"], + "properties": { + "fields": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "type"], + "properties": { + "name": { "type": "string" }, + "type": { "type": "string", "enum": ["api_key", "secret", "token"] }, + "label": { "type": "string" }, + "required": { "type": "boolean" } + } + } + } + } + }, + "getEntityTypeRequirementsResponse": { + "type": "object", + "description": "Authorization requirements response from GET /api/entities/types/:typeName/requirements", + "required": ["type", "step", "totalSteps", "isMultiStep"], + "properties": { + "type": { + "type": "string", + "enum": ["oauth2", "form", "api-key", "basic"], + "description": "Type of authentication required" + }, + "step": { + "type": "integer", + "minimum": 1, + "description": "Current step number (1-indexed)" + }, + "totalSteps": { + "type": "integer", + "minimum": 1, + "description": "Total number of steps in the auth flow" + }, + "isMultiStep": { + "type": "boolean", + "description": "Whether this is a multi-step authentication flow" + }, + "sessionId": { + "type": "string", + "description": "Session ID for multi-step flows (required after step 1)" + }, + "data": { + "type": "object", + "description": "Step-specific data (schema varies by type)", + "oneOf": [ + { "$ref": "#/definitions/oauth2Requirements" }, + { "$ref": "#/definitions/formRequirements" }, + { "$ref": "#/definitions/apiKeyRequirements" } + ] + } + } + }, + "authorizationRequest": { + "type": "object", + "description": "Authorization request to POST /api/authorize", + "required": ["entityType"], + "properties": { + "entityType": { + "type": "string", + "description": "Type of entity being authorized (module name)" + }, + "moduleType": { + "type": "string", + "description": "Module type (v2 API) - same as entityType" + }, + "data": { + "type": "object", + "description": "Authentication credentials or OAuth callback data", + "additionalProperties": true + }, + "step": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Current step number for multi-step flows" + }, + "sessionId": { + "type": "string", + "description": "Session ID from previous step (required for step > 1)" + }, + "connectingEntityType": { + "type": "string", + "description": "Type of entity being connected to (for paired integrations)" + } + } + }, + "authorizationResponse": { + "type": "object", + "description": "Authorization response (success or next step)", + "oneOf": [ + { "$ref": "#/definitions/authorizationSuccess" }, + { "$ref": "#/definitions/authorizationNextStep" } + ] + }, + "authorizationSuccess": { + "type": "object", + "description": "Successful authorization completion", + "required": ["entity_id", "credential_id", "type"], + "properties": { + "entity_id": { + "type": "string", + "description": "ID of created/updated entity" + }, + "credential_id": { + "type": "string", + "description": "ID of created/updated credential" + }, + "type": { + "type": "string", + "description": "Entity type (module name)" + }, + "display": { + "type": "string", + "description": "Display name for the entity" + } + } + }, + "authorizationNextStep": { + "type": "object", + "description": "Next step in multi-step authorization", + "required": ["nextStep", "sessionId", "requirements"], + "properties": { + "nextStep": { + "type": "integer", + "minimum": 2, + "description": "Next step number" + }, + "sessionId": { + "type": "string", + "description": "Session ID to use for next step" + }, + "requirements": { + "$ref": "#/definitions/authorizationRequirements", + "description": "Requirements for the next step" + }, + "message": { + "type": "string", + "description": "Message to display to user (e.g., 'OTP sent to your email')" + } + } + }, + "authorizationSession": { + "type": "object", + "description": "Authorization session stored in database", + "required": ["sessionId", "userId", "entityType", "currentStep", "maxSteps", "expiresAt"], + "properties": { + "id": { + "type": "string", + "description": "Database ID (ObjectId for MongoDB, integer for PostgreSQL)" + }, + "sessionId": { + "type": "string", + "format": "uuid", + "description": "Unique session identifier" + }, + "userId": { + "type": "string", + "description": "User ID initiating the auth flow" + }, + "entityType": { + "type": "string", + "description": "Type of entity being authorized" + }, + "currentStep": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Current step in the flow" + }, + "maxSteps": { + "type": "integer", + "minimum": 1, + "description": "Total number of steps" + }, + "stepData": { + "type": "object", + "description": "Data collected from previous steps", + "additionalProperties": true + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "Session expiration timestamp" + }, + "completed": { + "type": "boolean", + "default": false, + "description": "Whether the session has completed successfully" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Session creation timestamp" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp" + } + } + } + } +} diff --git a/packages/schemas/schemas/api-credentials.schema.json b/packages/schemas/schemas/api-credentials.schema.json new file mode 100644 index 000000000..fda153faf --- /dev/null +++ b/packages/schemas/schemas/api-credentials.schema.json @@ -0,0 +1,176 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://friggframework.org/schemas/api-credentials.json", + "title": "Frigg Credentials API Schemas", + "description": "JSON schemas for credentials API requests and responses", + "definitions": { + "credential": { + "type": "object", + "description": "A credential object representing authentication data for an external system. Tokens are masked in responses.", + "required": ["id", "type", "userId", "authIsValid"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the credential" + }, + "type": { + "type": "string", + "description": "Module type (e.g., 'hubspot', 'salesforce', 'slack')" + }, + "externalId": { + "type": "string", + "description": "ID from the external system (e.g., account ID, organization ID)" + }, + "userId": { + "type": "string", + "description": "ID of the user who owns this credential" + }, + "authIsValid": { + "type": "boolean", + "description": "Whether the authentication is currently valid" + }, + "entityCount": { + "type": "integer", + "minimum": 0, + "description": "Number of entities using this credential" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the credential was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the credential was last updated" + } + } + }, + "listCredentialsResponse": { + "type": "object", + "description": "Response from GET /api/credentials - returns list of credentials for the authenticated user", + "required": ["credentials"], + "properties": { + "credentials": { + "type": "array", + "items": { + "$ref": "#/definitions/credential" + }, + "description": "Array of credential objects (tokens masked)" + } + } + }, + "getCredentialResponse": { + "description": "Response from GET /api/credentials/:id - returns a single credential", + "allOf": [ + { + "$ref": "#/definitions/credential" + } + ] + }, + "deleteCredentialResponse": { + "type": "object", + "description": "Response from DELETE /api/credentials/:id", + "required": ["success"], + "properties": { + "success": { + "type": "boolean", + "description": "Whether the deletion was successful" + }, + "message": { + "type": "string", + "description": "Optional message providing additional context" + } + } + }, + "reauthorizeCredentialRequest": { + "type": "object", + "description": "Request to POST /api/credentials/:id/reauthorize - refresh or update credential authentication", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "description": "Authentication data (OAuth code, API keys, form fields, etc.)", + "additionalProperties": true + }, + "step": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Current step number for multi-step reauthorization flows" + }, + "sessionId": { + "type": "string", + "description": "Session ID from previous step (required for step > 1)" + } + } + }, + "reauthorizeCredentialResponse": { + "type": "object", + "description": "Response from POST /api/credentials/:id/reauthorize - either success or next step", + "oneOf": [ + { + "$ref": "#/definitions/reauthorizeCredentialSuccess" + }, + { + "$ref": "#/definitions/reauthorizeCredentialNextStep" + } + ] + }, + "reauthorizeCredentialSuccess": { + "type": "object", + "description": "Successful reauthorization completion", + "required": ["success", "credential_id", "authIsValid"], + "properties": { + "success": { + "type": "boolean", + "const": true, + "description": "Indicates successful reauthorization" + }, + "credential_id": { + "type": "string", + "description": "ID of the reauthorized credential" + }, + "authIsValid": { + "type": "boolean", + "const": true, + "description": "Authentication is now valid" + }, + "message": { + "type": "string", + "description": "Optional success message" + } + } + }, + "reauthorizeCredentialNextStep": { + "type": "object", + "description": "Next step in multi-step reauthorization flow", + "required": ["step", "totalSteps", "sessionId", "requirements"], + "properties": { + "step": { + "type": "integer", + "minimum": 2, + "description": "Next step number" + }, + "totalSteps": { + "type": "integer", + "minimum": 2, + "description": "Total number of steps in the flow" + }, + "sessionId": { + "type": "string", + "description": "Session ID to use for the next step" + }, + "requirements": { + "type": "object", + "description": "Requirements for the next step (form fields, OAuth URL, etc.)", + "additionalProperties": true + }, + "message": { + "type": "string", + "description": "Message to display to user (e.g., 'OTP sent to your email')" + } + } + } + } +} diff --git a/packages/schemas/schemas/api-entities.schema.json b/packages/schemas/schemas/api-entities.schema.json new file mode 100644 index 000000000..429086393 --- /dev/null +++ b/packages/schemas/schemas/api-entities.schema.json @@ -0,0 +1,292 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://friggframework.org/schemas/api-entities.json", + "title": "Frigg Entities API Schemas", + "description": "JSON schemas for entities API requests and responses", + "definitions": { + "entity": { + "type": "object", + "description": "A connected account/entity object representing an external integration", + "required": ["id", "type", "credentialId", "userId"], + "properties": { + "id": { + "type": "string", + "description": "Unique entity identifier" + }, + "type": { + "type": "string", + "description": "Module/entity type name (e.g., 'hubspot', 'salesforce')" + }, + "name": { + "type": "string", + "description": "Display name for the entity" + }, + "externalId": { + "type": "string", + "description": "ID from the external system (e.g., HubSpot portal ID)" + }, + "credentialId": { + "type": "string", + "description": "ID of the linked credential" + }, + "userId": { + "type": "string", + "description": "ID of the user who owns this entity" + }, + "authIsValid": { + "type": "boolean", + "description": "Whether authentication is currently valid" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Entity creation timestamp" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp" + } + } + }, + "listEntitiesResponse": { + "type": "object", + "description": "Response from GET /api/entities", + "required": ["entities"], + "properties": { + "entities": { + "type": "array", + "items": { + "$ref": "#/definitions/entity" + }, + "description": "List of entities for the authenticated user" + } + } + }, + "createEntityRequest": { + "type": "object", + "description": "Request to POST /api/entities", + "required": ["entityType", "data"], + "properties": { + "entityType": { + "type": "string", + "description": "Type of entity to create (module name)" + }, + "data": { + "type": "object", + "description": "Entity creation data (must contain credential_id)", + "required": ["credential_id"], + "properties": { + "credential_id": { + "type": "string", + "description": "ID of the credential to link to this entity" + } + }, + "additionalProperties": true + } + } + }, + "createEntityResponse": { + "type": "object", + "description": "Response from POST /api/entities", + "required": ["entity_id", "credential_id", "type"], + "properties": { + "entity_id": { + "type": "string", + "description": "ID of the created entity" + }, + "credential_id": { + "type": "string", + "description": "ID of the linked credential" + }, + "type": { + "type": "string", + "description": "Entity type (module name)" + } + } + }, + "entityType": { + "type": "object", + "description": "Metadata about an available entity type", + "required": ["type", "name"], + "properties": { + "type": { + "type": "string", + "description": "Module name (e.g., 'hubspot', 'salesforce')" + }, + "name": { + "type": "string", + "description": "Display name for the entity type" + }, + "description": { + "type": "string", + "description": "Description of the entity type" + }, + "authType": { + "type": "string", + "enum": ["oauth2", "form", "api-key", "basic"], + "description": "Type of authentication required" + }, + "isMultiStep": { + "type": "boolean", + "description": "Whether this entity type uses multi-step authentication" + }, + "stepCount": { + "type": "integer", + "minimum": 1, + "description": "Number of steps in the authentication flow" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of capabilities this entity type supports" + } + } + }, + "listEntityTypesResponse": { + "type": "object", + "description": "Response from GET /api/entities/types", + "required": ["types"], + "properties": { + "types": { + "type": "array", + "items": { + "$ref": "#/definitions/entityType" + }, + "description": "List of available entity types" + } + } + }, + "getEntityTypeResponse": { + "type": "object", + "description": "Response from GET /api/entities/types/:typeName", + "required": ["type", "name"], + "properties": { + "type": { + "type": "string", + "description": "Module name (e.g., 'hubspot', 'salesforce')" + }, + "name": { + "type": "string", + "description": "Display name for the entity type" + }, + "description": { + "type": "string", + "description": "Description of the entity type" + }, + "authType": { + "type": "string", + "enum": ["oauth2", "form", "api-key", "basic"], + "description": "Type of authentication required" + }, + "isMultiStep": { + "type": "boolean", + "description": "Whether this entity type uses multi-step authentication" + }, + "stepCount": { + "type": "integer", + "minimum": 1, + "description": "Number of steps in the authentication flow" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of capabilities this entity type supports" + } + } + }, + "reauthorizeEntityRequest": { + "type": "object", + "description": "Request to POST /api/entities/:id/reauthorize", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "description": "Authentication data (OAuth code, form fields, API keys, etc.)", + "additionalProperties": true + }, + "step": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Current step number for multi-step flows" + }, + "sessionId": { + "type": "string", + "description": "Session ID from previous step (required for step > 1)" + } + } + }, + "reauthorizeEntityResponse": { + "type": "object", + "description": "Response from POST /api/entities/:id/reauthorize (success or next step)", + "oneOf": [ + { + "$ref": "#/definitions/reauthorizeEntitySuccess" + }, + { + "$ref": "#/definitions/reauthorizeEntityNextStep" + } + ] + }, + "reauthorizeEntitySuccess": { + "type": "object", + "description": "Successful reauthorization completion", + "required": ["success", "credential_id", "entity_id", "authIsValid"], + "properties": { + "success": { + "type": "boolean", + "const": true, + "description": "Indicates successful reauthorization" + }, + "credential_id": { + "type": "string", + "description": "ID of the updated credential" + }, + "entity_id": { + "type": "string", + "description": "ID of the reauthorized entity" + }, + "authIsValid": { + "type": "boolean", + "const": true, + "description": "Confirms authentication is now valid" + } + } + }, + "reauthorizeEntityNextStep": { + "type": "object", + "description": "Next step in multi-step reauthorization", + "required": ["step", "totalSteps", "sessionId", "requirements"], + "properties": { + "step": { + "type": "integer", + "minimum": 2, + "description": "Next step number" + }, + "totalSteps": { + "type": "integer", + "minimum": 2, + "description": "Total number of steps in the flow" + }, + "sessionId": { + "type": "string", + "description": "Session ID to use for next step" + }, + "requirements": { + "type": "object", + "description": "Requirements for the next step (varies by auth type)", + "additionalProperties": true + }, + "message": { + "type": "string", + "description": "Message to display to user (e.g., 'OTP sent to your email')" + } + } + } + } +} diff --git a/packages/schemas/schemas/api-proxy.schema.json b/packages/schemas/schemas/api-proxy.schema.json new file mode 100644 index 000000000..6541ab636 --- /dev/null +++ b/packages/schemas/schemas/api-proxy.schema.json @@ -0,0 +1,251 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://friggframework.org/schemas/api-proxy.json", + "title": "Frigg Proxy API Schemas", + "description": "JSON schemas for proxy API requests and responses that forward calls to external APIs", + "definitions": { + "proxyRequest": { + "type": "object", + "description": "Proxy request to POST /api/entities/:id/proxy or POST /api/credentials/:id/proxy", + "required": ["method", "path"], + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"], + "description": "HTTP method to use for the upstream API request" + }, + "path": { + "type": "string", + "description": "API path to call on the upstream service (e.g., '/v3/contacts' or '/api/users')", + "pattern": "^/", + "minLength": 1 + }, + "query": { + "type": "object", + "description": "Query parameters to include in the request as key-value pairs", + "additionalProperties": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + } + }, + "headers": { + "type": "object", + "description": "Additional headers to include in the upstream request (authorization headers are added automatically)", + "additionalProperties": { "type": "string" } + }, + "body": { + "description": "Request body for POST, PUT, or PATCH requests (can be object, array, string, or null)", + "oneOf": [ + { "type": "object" }, + { "type": "array" }, + { "type": "string" }, + { "type": "null" } + ] + } + }, + "examples": [ + { + "method": "GET", + "path": "/v3/contacts", + "query": { + "limit": "10", + "archived": "false" + } + }, + { + "method": "POST", + "path": "/v3/contacts", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "properties": { + "email": "contact@example.com", + "firstname": "John", + "lastname": "Doe" + } + } + }, + { + "method": "PATCH", + "path": "/api/v1/users/12345", + "body": { + "status": "active" + } + } + ] + }, + "proxyResponse": { + "type": "object", + "description": "Successful proxy response with data from upstream API", + "required": ["success", "status", "data"], + "properties": { + "success": { + "type": "boolean", + "const": true, + "description": "Indicates successful proxy operation" + }, + "status": { + "type": "integer", + "minimum": 200, + "maximum": 299, + "description": "HTTP status code from the upstream API response" + }, + "headers": { + "type": "object", + "description": "Response headers from the upstream API", + "additionalProperties": { "type": "string" } + }, + "data": { + "description": "Response body from the upstream API (can be any valid JSON type)", + "oneOf": [ + { "type": "object" }, + { "type": "array" }, + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "type": "null" } + ] + } + }, + "examples": [ + { + "success": true, + "status": 200, + "headers": { + "content-type": "application/json", + "x-rate-limit-remaining": "998" + }, + "data": { + "results": [ + { + "id": "123", + "properties": { + "email": "contact@example.com", + "firstname": "John", + "lastname": "Doe" + } + } + ] + } + }, + { + "success": true, + "status": 201, + "data": { + "id": "456", + "created_at": "2025-01-15T10:30:00Z", + "status": "active" + } + } + ] + }, + "proxyErrorResponse": { + "type": "object", + "description": "Error response from proxy operation", + "required": ["success", "status", "error"], + "properties": { + "success": { + "type": "boolean", + "const": false, + "description": "Indicates failed proxy operation" + }, + "status": { + "type": "integer", + "minimum": 400, + "maximum": 599, + "description": "HTTP status code (either from upstream API or Frigg proxy error)" + }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Error code identifying the type of failure", + "enum": [ + "INVALID_AUTH", + "EXPIRED_TOKEN", + "UPSTREAM_ERROR", + "TIMEOUT", + "NETWORK_ERROR", + "RATE_LIMITED", + "INVALID_REQUEST", + "NOT_FOUND", + "PERMISSION_DENIED", + "INVALID_CREDENTIALS", + "SERVICE_UNAVAILABLE" + ] + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "details": { + "description": "Additional error details from upstream API or internal error information", + "oneOf": [ + { "type": "object" }, + { "type": "array" }, + { "type": "string" }, + { "type": "null" } + ] + }, + "upstreamStatus": { + "type": "integer", + "description": "Original HTTP status code from upstream API (if available)" + } + } + } + }, + "examples": [ + { + "success": false, + "status": 401, + "error": { + "code": "INVALID_AUTH", + "message": "Authentication credentials are invalid or expired", + "details": { + "category": "INVALID_AUTHENTICATION", + "message": "The access token provided is invalid or has expired" + }, + "upstreamStatus": 401 + } + }, + { + "success": false, + "status": 429, + "error": { + "code": "RATE_LIMITED", + "message": "Rate limit exceeded for this API", + "details": { + "retry_after": 60, + "limit": "100 requests per minute" + } + } + }, + { + "success": false, + "status": 504, + "error": { + "code": "TIMEOUT", + "message": "Request to upstream API timed out after 30 seconds" + } + } + ] + }, + "proxyResponseUnion": { + "description": "Union type representing either success or error proxy response", + "oneOf": [ + { "$ref": "#/definitions/proxyResponse" }, + { "$ref": "#/definitions/proxyErrorResponse" } + ] + } + } +} diff --git a/packages/schemas/schemas/app-definition.schema.json b/packages/schemas/schemas/app-definition.schema.json index c20c92871..e7195daa1 100644 --- a/packages/schemas/schemas/app-definition.schema.json +++ b/packages/schemas/schemas/app-definition.schema.json @@ -20,6 +20,25 @@ "enum": ["aws"], "default": "aws" }, + "label": { + "type": "string", + "description": "Human-readable display name for the application", + "minLength": 1, + "maxLength": 200, + "examples": ["My Frigg Application", "Customer Portal Integrations"] + }, + "managementMode": { + "type": "string", + "description": "Infrastructure management mode: 'managed' lets Frigg manage resources, 'discover' uses existing resources, 'custom' for manual configuration", + "enum": ["managed", "discover", "custom"], + "default": "managed" + }, + "vpcIsolation": { + "type": "string", + "description": "VPC isolation strategy: 'isolated' creates separate VPC per stage, 'shared' reuses VPC across stages", + "enum": ["isolated", "shared"], + "default": "isolated" + }, "environment": { "type": "object", "description": "Environment variable configuration (key: true/false flags)", diff --git a/packages/schemas/schemas/core-models.schema.json b/packages/schemas/schemas/core-models.schema.json index 713ba34da..757d2f1d4 100644 --- a/packages/schemas/schemas/core-models.schema.json +++ b/packages/schemas/schemas/core-models.schema.json @@ -18,12 +18,12 @@ "type": "object", "description": "Frigg User model schema", "required": [ - "_id" + "id" ], "properties": { - "_id": { + "id": { "$ref": "#/definitions/objectId", - "description": "Unique user identifier" + "description": "Unique user identifier (maps to _id in MongoDB)" }, "email": { "type": "string", @@ -148,13 +148,13 @@ "type": "object", "description": "Frigg Credential model schema", "required": [ - "_id", + "id", "userId" ], "properties": { - "_id": { + "id": { "$ref": "#/definitions/objectId", - "description": "Unique credential identifier" + "description": "Unique credential identifier (maps to _id in MongoDB)" }, "userId": { "$ref": "#/definitions/objectId", @@ -165,12 +165,12 @@ "description": "External system identifier", "maxLength": 255 }, - "auth_is_valid": { + "authIsValid": { "type": "boolean", "description": "Whether authentication is currently valid", "default": false }, - "authData": { + "data": { "type": "object", "description": "Encrypted authentication data", "properties": { @@ -263,14 +263,14 @@ "type": "object", "description": "Frigg Entity model schema", "required": [ - "_id", + "id", "credentialId", "userId" ], "properties": { - "_id": { + "id": { "$ref": "#/definitions/objectId", - "description": "Unique entity identifier" + "description": "Unique entity identifier (maps to _id in MongoDB)" }, "credentialId": { "$ref": "#/definitions/objectId", @@ -423,7 +423,7 @@ "examples": [ { "user": { - "_id": "507f1f77bcf86cd799439011", + "id": "507f1f77bcf86cd799439011", "email": "user@example.com", "firstName": "John", "lastName": "Doe", @@ -446,11 +446,11 @@ "updatedAt": "2023-01-01T00:00:00Z" }, "credential": { - "_id": "507f1f77bcf86cd799439012", + "id": "507f1f77bcf86cd799439012", "userId": "507f1f77bcf86cd799439011", "externalId": "12345", - "auth_is_valid": true, - "authData": { + "authIsValid": true, + "data": { "access_token": "encrypted_access_token", "refresh_token": "encrypted_refresh_token", "token_type": "Bearer", @@ -466,7 +466,7 @@ "updatedAt": "2023-01-01T00:00:00Z" }, "entity": { - "_id": "507f1f77bcf86cd799439013", + "id": "507f1f77bcf86cd799439013", "credentialId": "507f1f77bcf86cd799439012", "userId": "507f1f77bcf86cd799439011", "name": "HubSpot Contacts", diff --git a/packages/schemas/tests/schemas.test.js b/packages/schemas/tests/schemas.test.js index 7da6ea0ed..750c85e2a 100644 --- a/packages/schemas/tests/schemas.test.js +++ b/packages/schemas/tests/schemas.test.js @@ -92,7 +92,7 @@ describe('@friggframework/schemas', () => { organizationUserRequired: false, authModes: { friggToken: true, - xFriggHeaders: true, + sharedSecret: false, adopterJwt: false, }, }, @@ -109,7 +109,7 @@ describe('@friggframework/schemas', () => { usePassword: false, authModes: { friggToken: false, - xFriggHeaders: false, + sharedSecret: false, adopterJwt: true, }, jwtConfig: { @@ -204,7 +204,7 @@ describe('@friggframework/schemas', () => { organizationUserRequired: false, authModes: { friggToken: true, - xFriggHeaders: true, + sharedSecret: false, adopterJwt: false, }, fields: ['email', 'firstName', 'lastName'], @@ -470,7 +470,7 @@ describe('@friggframework/schemas', () => { test('should validate user model', () => { const models = { user: { - _id: "507f1f77bcf86cd799439011", + id: "507f1f77bcf86cd799439011", email: "test@example.com", role: "user", isActive: true, @@ -478,7 +478,7 @@ describe('@friggframework/schemas', () => { updatedAt: "2023-01-01T00:00:00Z" } }; - + const result = validateCoreModels(models); expect(result.valid).toBe(true); }); @@ -486,16 +486,20 @@ describe('@friggframework/schemas', () => { test('should validate credential model', () => { const models = { credential: { - _id: "507f1f77bcf86cd799439012", + id: "507f1f77bcf86cd799439012", userId: "507f1f77bcf86cd799439011", - subType: "hubspot", + externalId: "12345", authIsValid: true, + data: { + access_token: "encrypted_token", + refresh_token: "encrypted_refresh" + }, isActive: true, createdAt: "2023-01-01T00:00:00Z", updatedAt: "2023-01-01T00:00:00Z" } }; - + const result = validateCoreModels(models); expect(result.valid).toBe(true); }); @@ -503,18 +507,19 @@ describe('@friggframework/schemas', () => { test('should validate entity model', () => { const models = { entity: { - _id: "507f1f77bcf86cd799439013", + id: "507f1f77bcf86cd799439013", credentialId: "507f1f77bcf86cd799439012", userId: "507f1f77bcf86cd799439011", - subType: "contact", + moduleName: "hubspot", name: "Test Entity", + externalId: "contact_12345", status: "active", isActive: true, createdAt: "2023-01-01T00:00:00Z", updatedAt: "2023-01-01T00:00:00Z" } }; - + const result = validateCoreModels(models); expect(result.valid).toBe(true); });