Skip to content

Commit dd0d845

Browse files
kaposkecursoragent
andcommitted
refactor(auth): replace NangoError prefix scraping with CustomerKeyError
Use an explicit typed error from customerKeyService so controllers can match on instanceof instead of depending on NangoError's unhandled_ prefix. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a3bbece commit dd0d845

8 files changed

Lines changed: 44 additions & 52 deletions

File tree

packages/server/lib/controllers/shared/customerKeyError.ts

Lines changed: 0 additions & 20 deletions
This file was deleted.

packages/server/lib/controllers/v1/account/apiKeys/createApiKey.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import * as z from 'zod';
22

33
import db from '@nangohq/database';
4-
import { customerKeyService } from '@nangohq/shared';
4+
import { CustomerKeyError, customerKeyService } from '@nangohq/shared';
55
import { report, requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';
66

77
import { asyncWrapper } from '../../../../utils/asyncWrapper.js';
8-
import { getCustomerKeyErrorType } from '../../../shared/customerKeyError.js';
98

109
import type { AccountApiKeyScope, CreateAccountApiKey } from '@nangohq/types';
1110

@@ -35,10 +34,9 @@ export const createAccountApiKey = asyncWrapper<CreateAccountApiKey>(async (req,
3534
});
3635

3736
if (result.isErr()) {
38-
const errType = getCustomerKeyErrorType(result.error);
39-
if (errType === 'duplicate_api_key') {
37+
if (result.error instanceof CustomerKeyError && result.error.code === 'duplicate_api_key') {
4038
res.status(409).send({ error: { code: 'conflict', message: 'A key with this name already exists' } });
41-
} else if (errType === 'resource_capped') {
39+
} else if (result.error instanceof CustomerKeyError && result.error.code === 'resource_capped') {
4240
res.status(400).send({ error: { code: 'resource_capped', message: 'Maximum number of account API keys reached' } });
4341
} else {
4442
report(result.error);

packages/server/lib/controllers/v1/account/apiKeys/deleteApiKey.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import * as z from 'zod';
22

33
import db from '@nangohq/database';
4-
import { customerKeyService } from '@nangohq/shared';
4+
import { CustomerKeyError, customerKeyService } from '@nangohq/shared';
55
import { report, requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';
66

77
import { asyncWrapper } from '../../../../utils/asyncWrapper.js';
8-
import { getCustomerKeyErrorType } from '../../../shared/customerKeyError.js';
98

109
import type { DeleteAccountApiKey } from '@nangohq/types';
1110

@@ -28,7 +27,7 @@ export const deleteAccountApiKey = asyncWrapper<DeleteAccountApiKey>(async (req,
2827

2928
const result = await customerKeyService.deleteAccountApiKey(db.knex, valParams.data.keyId, res.locals.account.id);
3029
if (result.isErr()) {
31-
if (getCustomerKeyErrorType(result.error) === 'no_such_api_secret') {
30+
if (result.error instanceof CustomerKeyError && result.error.code === 'no_such_api_secret') {
3231
res.status(404).send({ error: { code: 'not_found', message: 'Account API key not found' } });
3332
} else {
3433
report(result.error);

packages/server/lib/controllers/v1/environment/createApiKey.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import * as z from 'zod';
22

33
import db from '@nangohq/database';
4-
import { customerKeyService } from '@nangohq/shared';
4+
import { CustomerKeyError, customerKeyService } from '@nangohq/shared';
55
import { apiKeyScopes, report, requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';
66

77
import { asyncWrapperWithEnvironment } from '../../../utils/asyncWrapper.js';
8-
import { getCustomerKeyErrorType } from '../../shared/customerKeyError.js';
98

109
import type { ApiKeyScope, CreateApiKey } from '@nangohq/types';
1110

@@ -40,10 +39,9 @@ export const createApiKey = asyncWrapperWithEnvironment<CreateApiKey>(async (req
4039
});
4140

4241
if (result.isErr()) {
43-
const errType = getCustomerKeyErrorType(result.error);
44-
if (errType === 'duplicate_api_key') {
42+
if (result.error instanceof CustomerKeyError && result.error.code === 'duplicate_api_key') {
4543
res.status(409).send({ error: { code: 'conflict', message: 'A key with this name already exists' } });
46-
} else if (errType === 'resource_capped') {
44+
} else if (result.error instanceof CustomerKeyError && result.error.code === 'resource_capped') {
4745
res.status(400).send({ error: { code: 'resource_capped', message: 'Maximum number of API keys per environment reached' } });
4846
} else {
4947
report(result.error);

packages/server/lib/controllers/v1/environment/deleteApiKey.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import * as z from 'zod';
22

33
import db from '@nangohq/database';
4-
import { customerKeyService } from '@nangohq/shared';
4+
import { CustomerKeyError, customerKeyService } from '@nangohq/shared';
55
import { report, requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';
66

77
import { asyncWrapperWithEnvironment } from '../../../utils/asyncWrapper.js';
8-
import { getCustomerKeyErrorType } from '../../shared/customerKeyError.js';
98

109
import type { DeleteApiKey } from '@nangohq/types';
1110

@@ -32,7 +31,7 @@ export const deleteApiKey = asyncWrapperWithEnvironment<DeleteApiKey>(async (req
3231

3332
const result = await customerKeyService.deleteCustomerKey(db.knex, keyId, environment.id);
3433
if (result.isErr()) {
35-
if (getCustomerKeyErrorType(result.error) === 'no_such_api_secret') {
34+
if (result.error instanceof CustomerKeyError && result.error.code === 'no_such_api_secret') {
3635
res.status(404).send({ error: { code: 'not_found', message: 'API key not found' } });
3736
} else {
3837
report(result.error);

packages/server/lib/controllers/v1/environment/patchApiKey.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import * as z from 'zod';
22

33
import db from '@nangohq/database';
4-
import { customerKeyService } from '@nangohq/shared';
5-
import { apiKeyScopes, zodErrorToHTTP } from '@nangohq/utils';
4+
import { CustomerKeyError, customerKeyService } from '@nangohq/shared';
5+
import { apiKeyScopes, report, zodErrorToHTTP } from '@nangohq/utils';
66

77
import { asyncWrapperWithEnvironment } from '../../../utils/asyncWrapper.js';
88

@@ -38,11 +38,13 @@ export const patchApiKey = asyncWrapperWithEnvironment<PatchApiKey>(async (req,
3838
if (parsed.data.display_name) {
3939
const result = await customerKeyService.renameApiKey(db.knex, keyId, parsed.data.display_name, environment.id, account.id);
4040
if (result.isErr()) {
41-
const { type: errType = '', message: errMsg = '' } = result.error as { type?: string; message?: string };
42-
if (errType === 'duplicate_api_key' || errMsg.includes('duplicate_api_key')) {
41+
if (result.error instanceof CustomerKeyError && result.error.code === 'duplicate_api_key') {
4342
res.status(409).send({ error: { code: 'conflict', message: 'A key with this name already exists' } });
44-
} else {
43+
} else if (result.error instanceof CustomerKeyError && result.error.code === 'no_such_api_secret') {
4544
res.status(404).send({ error: { code: 'not_found', message: 'API key not found' } });
45+
} else {
46+
report(result.error);
47+
res.status(500).send({ error: { code: 'server_error', message: 'Failed to rename API key' } });
4648
}
4749
return;
4850
}
@@ -51,7 +53,12 @@ export const patchApiKey = asyncWrapperWithEnvironment<PatchApiKey>(async (req,
5153
if (parsed.data.scopes) {
5254
const result = await customerKeyService.updateApiKeyScopes(db.knex, keyId, parsed.data.scopes, environment.id);
5355
if (result.isErr()) {
54-
res.status(404).send({ error: { code: 'not_found', message: 'API key not found' } });
56+
if (result.error instanceof CustomerKeyError && result.error.code === 'no_such_api_secret') {
57+
res.status(404).send({ error: { code: 'not_found', message: 'API key not found' } });
58+
} else {
59+
report(result.error);
60+
res.status(500).send({ error: { code: 'server_error', message: 'Failed to update API key scopes' } });
61+
}
5562
return;
5663
}
5764
}

packages/shared/lib/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export * from './services/tags.service.js';
4040
export * from './services/tags/schema.js';
4141
export * as gettingStartedService from './services/getting-started.service.js';
4242
export { MFAError } from './services/mfa.service.js';
43-
export { MAX_API_KEYS_PER_ACCOUNT } from './services/customerKey.service.js';
43+
export { CustomerKeyError, MAX_API_KEYS_PER_ACCOUNT } from './services/customerKey.service.js';
4444
export * from './services/invitations.js';
4545
export * from './services/providers.js';
4646
export * from './services/proxy/utils.js';

packages/shared/lib/services/customerKey.service.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ const webhookSigningKeyCache = new Map<number, string>();
1919
export const MAX_API_KEYS_PER_ENV = 50;
2020
export const MAX_API_KEYS_PER_ACCOUNT = 50;
2121

22+
export type CustomerKeyErrorCode = 'duplicate_api_key' | 'resource_capped' | 'no_such_api_secret';
23+
24+
export class CustomerKeyError extends Error {
25+
constructor(
26+
public readonly code: CustomerKeyErrorCode,
27+
public readonly payload: Record<string, unknown> = {}
28+
) {
29+
super(code);
30+
}
31+
}
32+
2233
type AccountApiKeyMetadata = Pick<DBCustomerKey, 'id' | 'display_name' | 'scopes' | 'last_used_at' | 'created_at'>;
2334

2435
class CustomerKeyService {
@@ -127,12 +138,12 @@ class CustomerKeyService {
127138
const existing = await this.activeAccountApiKeys(innerTrx, accountId).select('id').where('display_name', displayName).first();
128139

129140
if (existing) {
130-
throw new NangoError('duplicate_api_key', { display_name: displayName });
141+
throw new CustomerKeyError('duplicate_api_key', { display_name: displayName });
131142
}
132143

133144
const count = await this.activeAccountApiKeys(innerTrx, accountId).count<{ total: string }[]>('* as total').first();
134145
if (count && Number(count.total) >= MAX_API_KEYS_PER_ACCOUNT) {
135-
throw new NangoError('resource_capped', { max: MAX_API_KEYS_PER_ACCOUNT });
146+
throw new CustomerKeyError('resource_capped', { max: MAX_API_KEYS_PER_ACCOUNT });
136147
}
137148

138149
const inserted = await this.insertApiKey(innerTrx, { accountId, displayName, scopes });
@@ -178,7 +189,7 @@ class CustomerKeyService {
178189
.first();
179190

180191
if (existing) {
181-
throw new NangoError('duplicate_api_key', { display_name: displayName });
192+
throw new CustomerKeyError('duplicate_api_key', { display_name: displayName });
182193
}
183194

184195
// Check max keys per environment
@@ -191,7 +202,7 @@ class CustomerKeyService {
191202
.count('* as total')
192203
.first();
193204
if (count && Number(count['total']) >= MAX_API_KEYS_PER_ENV) {
194-
throw new NangoError('resource_capped', { max: MAX_API_KEYS_PER_ENV });
205+
throw new CustomerKeyError('resource_capped', { max: MAX_API_KEYS_PER_ENV });
195206
}
196207

197208
const inserted = await this.insertApiKey(innerTrx, {
@@ -344,7 +355,7 @@ class CustomerKeyService {
344355
.first();
345356

346357
if (existing) {
347-
throw new NangoError('duplicate_api_key', { display_name: displayName });
358+
throw new CustomerKeyError('duplicate_api_key', { display_name: displayName });
348359
}
349360

350361
const updated = await innerTrx<DBCustomerKey>(CUSTOMER_KEYS_TABLE)
@@ -360,7 +371,7 @@ class CustomerKeyService {
360371
})
361372
.update({ display_name: displayName, updated_at: innerTrx.fn.now() as unknown as Date });
362373
if (updated === 0) {
363-
throw new NangoError('no_such_api_secret', { id: keyId });
374+
throw new CustomerKeyError('no_such_api_secret', { id: keyId });
364375
}
365376
});
366377
return Ok();
@@ -384,7 +395,7 @@ class CustomerKeyService {
384395
})
385396
.update({ scopes, updated_at: trx.fn.now() as unknown as Date });
386397
if (updated === 0) {
387-
return Err(new NangoError('no_such_api_secret', { id: keyId }));
398+
return Err(new CustomerKeyError('no_such_api_secret', { id: keyId }));
388399
}
389400
return Ok();
390401
} catch (err) {
@@ -409,7 +420,7 @@ class CustomerKeyService {
409420
deleted_at: trx.fn.now() as unknown as Date
410421
});
411422
if (updated === 0) {
412-
return Err(new NangoError('no_such_api_secret', { id: keyId }));
423+
return Err(new CustomerKeyError('no_such_api_secret', { id: keyId }));
413424
}
414425
return Ok();
415426
} catch (err) {
@@ -423,7 +434,7 @@ class CustomerKeyService {
423434
.where(`${CUSTOMER_KEYS_TABLE}.id`, keyId)
424435
.update({ deleted_at: trx.fn.now() as unknown as Date });
425436
if (updated === 0) {
426-
return Err(new NangoError('no_such_api_secret', { id: keyId }));
437+
return Err(new CustomerKeyError('no_such_api_secret', { id: keyId }));
427438
}
428439
return Ok();
429440
} catch (err) {

0 commit comments

Comments
 (0)