From b87c64c2eda5ce7e45cf4e3cba31a65bace9919c Mon Sep 17 00:00:00 2001 From: Keyvan Chamani Date: Thu, 31 Oct 2024 17:07:58 -0400 Subject: [PATCH 1/5] feat: k1ch/ introduce POST:/clients/{client_id}/permissions --- database/layer/admin-permission.js | 39 +++++++++++++++- .../src/api_endpoints/clients/permissions.js | 32 +++++++++++++ server/src/api_endpoints/clients/utils.js | 29 +++++++++++- server/the-usher-openapi-spec.yaml | 45 +++++++++++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 server/src/api_endpoints/clients/permissions.js diff --git a/database/layer/admin-permission.js b/database/layer/admin-permission.js index 7d15772..ef910b6 100644 --- a/database/layer/admin-permission.js +++ b/database/layer/admin-permission.js @@ -73,16 +73,53 @@ const getPermissionsByRoleKey = async (roleKey) => { return await usherDb('permissions') .join('rolepermissions', 'permissions.key', '=', 'rolepermissions.permissionkey') .where({ 'rolepermissions.rolekey': roleKey }) - .select('permissions.*'); + .select('permissions.*') + } catch (err) { + throw pgErrorHandler(err) + } +} + +/** + * Insert a new permission + * + * @param {Object} permissionObject - The data for the new permission + * @param {string} permissionObject.name - The name of permission + * @param {number} permissionObject.clientkey - A valid client key + * @param {string} permissionObject.description - A description of the permission + * @returns {Promise} - A promise that resolves to the inserted permission object + */ +const insertPermission = async (permissionObject) => { + try { + const [permission] = await usherDb('permissions').insert(permissionObject).returning('*') + return permission } catch (err) { throw pgErrorHandler(err); } } +/** + * Get permissions by name and clientKey + * + * @param {string} name - The name of the permission + * @param {number} clientKey - The client key + * @returns {Promise>} - A promise that resolves to an array of permissions + */ +const getPermissionsByNameClientKey = async (name, clientKey) => { + try { + const permissions = await usherDb('permissions') + .where({ name, clientkey: clientKey }) + return permissions + } catch (err) { + throw pgErrorHandler(err) + } +} + module.exports = { insertPermissionByClientId, updatePermissionByPermissionname, deletePermissionByPermissionname, getPermission, getPermissionsByRoleKey, + insertPermission, + getPermissionsByNameClientKey, } diff --git a/server/src/api_endpoints/clients/permissions.js b/server/src/api_endpoints/clients/permissions.js new file mode 100644 index 0000000..c330f08 --- /dev/null +++ b/server/src/api_endpoints/clients/permissions.js @@ -0,0 +1,32 @@ +const createError = require('http-errors') +const dbAdminPermission = require('database/layer/admin-permission') +const { checkClientExists, checkPermissionNameUniqueness } = require('./utils') + +/** + * HTTP Request handler + * Create a permission + * + * @param {Object} req - The request object + * @param {Object} res - The response object to send 201 statusCode and the cerated permission on success + * @param {Function} next - The next middleware function + * @returns {Promise} - A Promise that resolves to void when the permission is created + */ +const createPermission = async (req, res, next) => { + try { + const { client_id: clientId } = req.params + const client = await checkClientExists(clientId) + const payload = { + ...req.body, + clientkey: client.key, + } + await checkPermissionNameUniqueness(payload) + const permission = await dbAdminPermission.insertPermission(payload) + res.status(201).send(permission) + } catch ({ httpStatusCode = 500, message }) { + return next(createError(httpStatusCode, { message })) + } +} + +module.exports = { + createPermission, +} diff --git a/server/src/api_endpoints/clients/utils.js b/server/src/api_endpoints/clients/utils.js index 7ded761..d8b4f92 100644 --- a/server/src/api_endpoints/clients/utils.js +++ b/server/src/api_endpoints/clients/utils.js @@ -1,8 +1,9 @@ const dbAdminRole = require('database/layer/admin-client') +const dbAdminPermission = require('database/layer/admin-permission') const checkClientExists = async (clientId) => { try { - await dbAdminRole.getClient(clientId); + return await dbAdminRole.getClient(clientId); } catch { throw { httpStatusCode: 404, @@ -11,6 +12,32 @@ const checkClientExists = async (clientId) => { } } +/** + * Checks the uniqueness of a permission name for a given client key. + * + * This function queries the database to retrieve permissions by name and client key. + * If any permissions are found, it throws an error indicating the name is already taken. + * + * @async + * @function checkPermissionNameUniqueness + * @param {Object} params - The parameters for checking uniqueness. + * @param {string} params.name - The name of the permission to check. + * @param {string} params.clientkey - The client key associated with the permission. + * @throws {Object} Throws an error with HTTP status code 409 if the permission name is not unique. + * @throws {number} error.httpStatusCode - The HTTP status code indicating conflict (409). + * @throws {string} error.message - The error message indicating the permission name is taken. + */ +const checkPermissionNameUniqueness = async ({ name, clientkey: clientKey }) => { + const permissions = await dbAdminPermission.getPermissionsByNameClientKey(name, clientKey); + if (permissions?.length) { + throw { + httpStatusCode: 409, + message: 'The permission name is taken!' + }; + } +}; + module.exports = { checkClientExists, + checkPermissionNameUniqueness, } diff --git a/server/the-usher-openapi-spec.yaml b/server/the-usher-openapi-spec.yaml index 72eb7cd..ca478f5 100644 --- a/server/the-usher-openapi-spec.yaml +++ b/server/the-usher-openapi-spec.yaml @@ -1022,6 +1022,50 @@ paths: 404: $ref: '#/components/responses/NotFound' + /clients/{client_id}/permissions: + parameters: + - $ref: '#/components/parameters/clientIdPathParam' + post: + 'x-swagger-router-controller': 'clients/permissions' + summary: Create a new permission for the given client + operationId: createPermission + tags: + - Client Admin APIs + security: + - bearerAdminAuth: [] + - bearerClientAdminAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + $ref: '#/components/schemas/EntityNameDef' + description: + $ref: '#/components/schemas/EntityDescriptionDef' + required: + - name + additionalProperties: false + responses: + 201: + description: Returns the created permission + content: + application/json: + schema: + $ref: "#/components/schemas/PermissionObject" + 400: + $ref: '#/components/responses/BadRequest' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 500: + $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' + /sessions: delete: operationId: invalidateSession @@ -1269,6 +1313,7 @@ components: $ref: '#/components/schemas/EntityNameDef' description: $ref: '#/components/schemas/EntityDescriptionDef' + nullable: true created_at: type: string format: date-time From c1293448a2b06dddb819d0767cd550230b079bad Mon Sep 17 00:00:00 2001 From: Keyvan Chamani Date: Fri, 1 Nov 2024 11:40:47 -0400 Subject: [PATCH 2/5] chore: k1ch / modify pgErrorHandler to return Error object --- database/utils/pgErrorHandler.js | 62 ++++++++++++++++---------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/database/utils/pgErrorHandler.js b/database/utils/pgErrorHandler.js index f555fd2..6993007 100644 --- a/database/utils/pgErrorHandler.js +++ b/database/utils/pgErrorHandler.js @@ -6,79 +6,81 @@ const { PgErrorCodes } = require('../constant/PgErrorCodes') * @returns {message: text, httpStatusCode: number} */ const pgErrorHandler = (pgDbError) => { - const error = {} + let errorMessage; + let httpStatusCode; switch (pgDbError.code) { case PgErrorCodes.UniqueViolation: - error.message = 'The operation would result in duplicate resources!' - error.httpStatusCode = 409 + errorMessage = 'The operation would result in duplicate resources!' + httpStatusCode = 409 break case PgErrorCodes.CheckViolation: - error.message = 'The operation would violate a check constraint!' - error.httpStatusCode = 400 + errorMessage = 'The operation would violate a check constraint!' + httpStatusCode = 400 break case PgErrorCodes.NotNullViolation: - error.message = 'A required value is missing!' - error.httpStatusCode = 400 + errorMessage = 'A required value is missing!' + httpStatusCode = 400 break case PgErrorCodes.ForeignKeyViolation: - error.message = 'Referenced resource is invalid!' - error.httpStatusCode = 400 + errorMessage = 'Referenced resource is invalid!' + httpStatusCode = 400 break case PgErrorCodes.InvalidTextRepresentation: - error.message = 'The provided data format is invalid!' - error.httpStatusCode = 400 + errorMessage = 'The provided data format is invalid!' + httpStatusCode = 400 break case PgErrorCodes.UndefinedColumn: - error.message = 'Internal DB Error: Bad query - Specified column is invalid!' - error.httpStatusCode = 500 + errorMessage = 'Internal DB Error: Bad query - Specified column is invalid!' + httpStatusCode = 500 break case PgErrorCodes.SerializationFailure: - error.message = 'Internal DB Error: A transaction serialization error occurred!' - error.httpStatusCode = 500 + errorMessage = 'Internal DB Error: A transaction serialization error occurred!' + httpStatusCode = 500 break case PgErrorCodes.DeadlockDetected: - error.message = 'Internal DB Error: The operation was halted due to a potential deadlock!' - error.httpStatusCode = 500 + errorMessage = 'Internal DB Error: The operation was halted due to a potential deadlock!' + httpStatusCode = 500 break case PgErrorCodes.SyntaxError: - error.message = 'Internal DB Error: There is a syntax error in the provided SQL or data!' - error.httpStatusCode = 500 + errorMessage = 'Internal DB Error: There is a syntax error in the provided SQL or data!' + httpStatusCode = 500 break case PgErrorCodes.UndefinedTable: - error.message = 'Internal DB Error: The table or view you are trying to access does not exist!' - error.httpStatusCode = 500 + errorMessage = 'Internal DB Error: The table or view you are trying to access does not exist!' + httpStatusCode = 500 break case PgErrorCodes.DiskFull: - error.message = 'Internal DB Error: The operation failed due to insufficient disk space!' - error.httpStatusCode = 500 + errorMessage = 'Internal DB Error: The operation failed due to insufficient disk space!' + httpStatusCode = 500 break case PgErrorCodes.OutOfMemory: - error.message = 'Internal DB Error: The system ran out of memory!' - error.httpStatusCode = 500 + errorMessage = 'Internal DB Error: The system ran out of memory!' + httpStatusCode = 500 break case PgErrorCodes.TooManyConnections: - error.message = 'Internal DB Error: There are too many connections to the database!' - error.httpStatusCode = 500 + errorMessage = 'Internal DB Error: There are too many connections to the database!' + httpStatusCode = 500 break default: - error.message = `Unexpected DB Error - Code: ${pgDbError?.code}, Message: ${pgDbError?.message}, Error: ${JSON.stringify(pgDbError)}` - error.httpStatusCode = 503 + errorMessage = `Unexpected DB Error - Code: ${pgDbError?.code}, Message: ${pgDbError?.message}, Error: ${JSON.stringify(pgDbError)}` + httpStatusCode = 503 break } - + const error = new Error(errorMessage) + error.httpStatusCode = httpStatusCode return error } From 5457c7e280858df15f65189c3271bf03f5622afc Mon Sep 17 00:00:00 2001 From: Keyvan Chamani Date: Fri, 1 Nov 2024 11:59:02 -0400 Subject: [PATCH 3/5] chore: k1ch / add tests for admin-permission DB layer --- database/test/db-admin-permissions.test.js | 60 +++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/database/test/db-admin-permissions.test.js b/database/test/db-admin-permissions.test.js index a11f422..8a1310e 100644 --- a/database/test/db-admin-permissions.test.js +++ b/database/test/db-admin-permissions.test.js @@ -4,6 +4,15 @@ const adminPermissions = require('../layer/admin-permission') const { usherDb } = require('../layer/knex') describe('Admin permissions view', () => { + let validClientKey + let permissionTableColumns + + before(async () => { + const { key: clientKey } = await usherDb('clients').select('key').first() + validClientKey = clientKey + permissionTableColumns = Object.keys(await usherDb('permissions').columnInfo()) + }) + describe('Test Get permission', () => { let validPermissionKey @@ -28,9 +37,58 @@ describe('Admin permissions view', () => { .count('permissionkey as permission_count') .groupBy('rolekey') .orderBy('permission_count', 'desc') - .first(); + .first() const permissions = await adminPermissions.getPermissionsByRoleKey(rolekey) assert.equal(permission_count, permissions.length) }) }) + + describe('Insert Permission', () => { + it('Should insert a new permission successfully', async () => { + const permissionObject = { + name: 'Test Permission', + clientkey: validClientKey, + description: 'A test permission', + } + const insertedPermission = await adminPermissions.insertPermission(permissionObject) + assert.equal(insertedPermission.name, permissionObject.name) + assert.equal(insertedPermission.clientkey, permissionObject.clientkey) + assert.equal(insertedPermission.description, permissionObject.description) + assert.ok(permissionTableColumns.every((col) => col in insertedPermission)) + await usherDb('permissions').where({ key: insertedPermission.key }).del() + }) + + it('Should throw an error when inserting a permission with invalid clientkey', async () => { + const invalidPermissionObject = { + name: 'Invalid Test Permission', + clientkey: null, + description: 'This should fail', + } + try { + await adminPermissions.insertPermission(invalidPermissionObject) + assert.fail('Expected an error but did not get one') + } catch (err) { + assert.ok(err instanceof Error) + } + }) + }) + + describe('Get Permissions by Name and Client Key', () => { + it('Should return permissions for a given name and clientkey', async () => { + const permission = await usherDb('permissions').select('*').first() + const permissions = await adminPermissions.getPermissionsByNameClientKey( + permission.name, + permission.clientkey + ) + assert.ok(permissions.length > 0) + assert.equal(permissions[0].name, permission.name) + assert.equal(permissions[0].clientkey, permission.clientkey) + assert.ok(permissionTableColumns.every((col) => col in permissions[0])) + }) + + it('Should return an empty array if no permissions match the criteria', async () => { + const permissions = await adminPermissions.getPermissionsByNameClientKey('Nonexistent Name', 99999) + assert.deepEqual(permissions, []) + }) + }) }) From a3ce83edf02efa17553d1c6101e8f48b26bca6d6 Mon Sep 17 00:00:00 2001 From: Keyvan Chamani Date: Fri, 1 Nov 2024 13:52:40 -0400 Subject: [PATCH 4/5] chore: k1ch / add tests for POST:clients/{client_id}/permissions --- server/test/endpoint_clients.test.js | 69 ++++++++++++++++++++++++++++ server/the-usher-openapi-spec.yaml | 2 + 2 files changed, 71 insertions(+) diff --git a/server/test/endpoint_clients.test.js b/server/test/endpoint_clients.test.js index dca207c..3b6f6a6 100644 --- a/server/test/endpoint_clients.test.js +++ b/server/test/endpoint_clients.test.js @@ -264,4 +264,73 @@ describe('Admin Clients Endpoint Test', () => { assert.equal(response.status, 404) }) }) + + describe('Create Client permissions', () => { + let validClientId + let validClientKey + + /** + * POST /clients/{:client_id}/permissions + * HTTP request to create a permission for the give client + * + * @param {string} clientId - The subject client id which needs to be updated + * @param {string} payload - The request body payload to create a permission + * @param {Object} header - The request headers + * @returns {Promise} - A Promise which resolves to fetch.response + */ + const createClientPermissions = async (clientId, payload, header = requestHeaders) => { + return await fetch(`${url}/clients/${clientId}/permissions`, { + method: 'POST', + headers: header, + body: JSON.stringify(payload) + }) + } + + before(async () => { + const client = await usherDb('clients').select('*').first() + validClientId = client.client_id + validClientKey = client.key + }) + + it('should return 201 and create a permission', async () => { + const permissionToCreate = { + name: 'data:access:test', + description: 'This is a test permission!' + } + const response = await createClientPermissions(validClientId, permissionToCreate) + assert.equal(response.status, 201) + const permission = await response.json() + assert.equal(permission.name, permissionToCreate.name) + assert.equal(permission.description, permissionToCreate.description) + await usherDb('permissions').where({ key: permission.key }).del() + }) + + it('should return 400 for bad payload', async () => { + const permissionToCreate = { + description: 'This is a test permission!' + } + const response = await createClientPermissions(validClientId, permissionToCreate) + assert.equal(response.status, 400) + }) + + it('should return 401 due to lack of proper token', async () => { + const userAccessToken = await getTestUser1IdPToken() + const response = await createClientPermissions(validClientId, {}, { + ...requestHeaders, + Authorization: `Bearer ${userAccessToken}` + }) + assert.equal(response.status, 401) + }) + + it('should return 404 for non-existent client id', async () => { + const response = await createClientPermissions('invalid_client_id', { name: 'test permission' }) + assert.equal(response.status, 404) + }) + + it('should return 409 if permission name is already taken', async () => { + const { name: takenPermissionName } = await usherDb('permissions').where({ clientkey: validClientKey }).select('name').first() + const response = await createClientPermissions(validClientId, { name: takenPermissionName }) + assert.equal(response.status, 409) + }) + }) }) diff --git a/server/the-usher-openapi-spec.yaml b/server/the-usher-openapi-spec.yaml index ca478f5..605b021 100644 --- a/server/the-usher-openapi-spec.yaml +++ b/server/the-usher-openapi-spec.yaml @@ -1057,6 +1057,8 @@ paths: $ref: "#/components/schemas/PermissionObject" 400: $ref: '#/components/responses/BadRequest' + 401: + $ref: '#/components/responses/Unauthorized' 404: $ref: '#/components/responses/NotFound' 409: From 5808526e6dabfbe5af88a6bfe09610f530c82f45 Mon Sep 17 00:00:00 2001 From: Keyvan Chamani Date: Fri, 1 Nov 2024 14:04:18 -0400 Subject: [PATCH 5/5] refactor: k1ch / throw Error instead of object --- server/src/api_endpoints/clients/utils.js | 14 ++++----- server/src/api_endpoints/personas/utils.js | 35 ++++++++++------------ server/src/api_endpoints/roles/utils.js | 7 ++--- server/src/utils/token-utils.js | 13 ++++---- 4 files changed, 30 insertions(+), 39 deletions(-) diff --git a/server/src/api_endpoints/clients/utils.js b/server/src/api_endpoints/clients/utils.js index d8b4f92..6b05ac9 100644 --- a/server/src/api_endpoints/clients/utils.js +++ b/server/src/api_endpoints/clients/utils.js @@ -5,10 +5,9 @@ const checkClientExists = async (clientId) => { try { return await dbAdminRole.getClient(clientId); } catch { - throw { - httpStatusCode: 404, - message: 'Client does not exist!', - } + const error = new Error('Client does not exist!') + error.httpStatusCode = 404 + throw error } } @@ -30,10 +29,9 @@ const checkClientExists = async (clientId) => { const checkPermissionNameUniqueness = async ({ name, clientkey: clientKey }) => { const permissions = await dbAdminPermission.getPermissionsByNameClientKey(name, clientKey); if (permissions?.length) { - throw { - httpStatusCode: 409, - message: 'The permission name is taken!' - }; + const error = new Error('The permission name is taken!') + error.httpStatusCode = 409 + throw error } }; diff --git a/server/src/api_endpoints/personas/utils.js b/server/src/api_endpoints/personas/utils.js index 21b25b1..041e7dd 100644 --- a/server/src/api_endpoints/personas/utils.js +++ b/server/src/api_endpoints/personas/utils.js @@ -7,20 +7,18 @@ const dbAdminPersonaPermissions = require('database/layer/admin-personapermissio const checkPersonaExists = async (personaKey) => { const persona = await dbAdminPersona.getPersona(personaKey) if (!persona) { - throw { - httpStatusCode: 404, - message: 'Persona does not exist!' - } + const error = new Error('Persona does not exist!') + error.httpStatusCode = 404 + throw error } } const checkPermissionExists = async (permissionKey) => { const permission = await dbAdminPermission.getPermission(permissionKey) if (!permission) { - throw { - httpStatusCode: 404, - message: 'Permission does not exist!' - } + const error = new Error('Permission does not exist!') + error.httpStatusCode = 404 + throw error } } @@ -35,10 +33,9 @@ const checkPermissionExists = async (permissionKey) => { const checkPersonaRolesValidity = async (personaKey, roleKeys) => { const validRoles = await dbAdminPersonaRoles.selectPersonaRolesInTheSameTenant(personaKey, roleKeys) if (validRoles.length !== roleKeys.length) { - throw { - httpStatusCode: 400, - message: 'Make sure to provide valid role keys which are associated with clients in the same tenant!', - } + const error = new Error('Make sure to provide valid role keys which are associated with clients in the same tenant!') + error.httpStatusCode = 400 + throw error } } @@ -53,20 +50,18 @@ const checkPersonaRolesValidity = async (personaKey, roleKeys) => { const checkPersonaPermissionsValidity = async (personaKey, permissionKeys) => { const validPermissions = await dbAdminPersonaPermissions.selectPersonaPermissionsInTheSameTenant(personaKey, permissionKeys) if (validPermissions.length !== permissionKeys.length) { - throw { - httpStatusCode: 400, - message: 'Make sure to provide valid permission keys which are associated with clients in the same tenant!', - } + const error = new Error('Make sure to provide valid permission keys which are associated with clients in the same tenant!') + error.httpStatusCode = 400 + throw error } } const checkRoleExists = async (roleKey) => { const role = await dbAdminRole.getRole(roleKey) if (!role) { - throw { - httpStatusCode: 404, - message: 'Role does not exist!' - } + const error = new Error('Role does not exist!') + error.httpStatusCode = 404 + throw error } } diff --git a/server/src/api_endpoints/roles/utils.js b/server/src/api_endpoints/roles/utils.js index e64c78e..b5e0499 100644 --- a/server/src/api_endpoints/roles/utils.js +++ b/server/src/api_endpoints/roles/utils.js @@ -3,10 +3,9 @@ const dbAdminRole = require('database/layer/admin-role') const checkRoleExists = async (roleKey) => { const role = await dbAdminRole.getRole(roleKey) if (!role) { - throw { - httpStatusCode: 404, - message: 'Role does not exist!' - } + const error = new Error('Role does not exist!') + error.httpStatusCode = 404 + throw error } } diff --git a/server/src/utils/token-utils.js b/server/src/utils/token-utils.js index a9d9606..dc5cfe9 100644 --- a/server/src/utils/token-utils.js +++ b/server/src/utils/token-utils.js @@ -12,7 +12,7 @@ const env = require('../../server-env') * @param {number} secondsUntilExpiry The duration (in seconds) the token should be valid for * @returns {number} expiry time as epoch milliseconds */ -function calculateAccessTokenExpiryDate (secondsUntilExpiry) { +function calculateAccessTokenExpiryDate(secondsUntilExpiry) { const utcNow = moment.utc() const expiryTime = utcNow.add(secondsUntilExpiry, 'seconds') const exp = moment(expiryTime).unix() @@ -56,10 +56,9 @@ async function obtainScopedRolesAndPermissions(subClaim, userContext, clientId, let roles = Array.from(new Set(rolesAndPermissionsRows.filter(row => (permissions ?? []).includes(row.permissionname)).map(row => row.rolename))) return { roles, permissions, xAcceptedOAuthScopes } } catch (err) { - throw { - message: 'Failed to obtainScopedRolesAndPermissions', - stack: err, - } + const error = new Error('Failed to obtainScopedRolesAndPermissions') + error.stack = err + throw error } } @@ -73,7 +72,7 @@ async function obtainScopedRolesAndPermissions(subClaim, userContext, clientId, * @param {string} scope Space separated list of Scopes * @param {number} secondsUntilExpiry The duration (in seconds) the token should be valid for */ -async function createSignedAccessToken (sub, azp, roles, scope, secondsUntilExpiry) { +async function createSignedAccessToken(sub, azp, roles, scope, secondsUntilExpiry) { const latestKeyPair = await keystore.selectLatestKey() const signedAccessToken = jwt.sign( @@ -103,7 +102,7 @@ async function createSignedAccessToken (sub, azp, roles, scope, secondsUntilExpi * @returns {number} Number of seconds remaining in session for use in new access token * **NOTE** This number can be negative if the idp token expiration timestamp has elapsed */ - function calculateSessionLifetimeExpiry (idpExpirationTimestamp) { +function calculateSessionLifetimeExpiry(idpExpirationTimestamp) { const utcNow = moment.utc() const idpExpirationDateTime = moment.utc(idpExpirationTimestamp)