From e05200891e5d5143c7704b13c9926b8db413add3 Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Sat, 11 May 2024 15:31:52 +0200 Subject: [PATCH 01/15] add basic api routes --- .../api/[project]/[lang]/translations/+server.ts | 13 +++++++++++++ src/routes/(api)/api/[project]/config/+server.ts | 5 +++++ 2 files changed, 18 insertions(+) create mode 100644 src/routes/(api)/api/[project]/[lang]/translations/+server.ts create mode 100644 src/routes/(api)/api/[project]/config/+server.ts diff --git a/src/routes/(api)/api/[project]/[lang]/translations/+server.ts b/src/routes/(api)/api/[project]/[lang]/translations/+server.ts new file mode 100644 index 00000000..77bf4fc7 --- /dev/null +++ b/src/routes/(api)/api/[project]/[lang]/translations/+server.ts @@ -0,0 +1,13 @@ +import type { RequestHandler } from './$types' + +export const POST: RequestHandler = ({ params }) => { + return new Response( + `getting POST for translations on project "${params.project}" and lang "${params.lang}"` + ) +} + +export const GET: RequestHandler = ({ params }) => { + return new Response( + `getting GET for translations on project "${params.project}" and lang "${params.lang}"` + ) +} diff --git a/src/routes/(api)/api/[project]/config/+server.ts b/src/routes/(api)/api/[project]/config/+server.ts new file mode 100644 index 00000000..f04960dd --- /dev/null +++ b/src/routes/(api)/api/[project]/config/+server.ts @@ -0,0 +1,5 @@ +import type { RequestHandler } from './$types' + +export const POST: RequestHandler = ({ params }) => { + return new Response(`getting post for config on project "${params.project}"`) +} From c599f65204f85a360c066bcdd182cb0ae1d79545 Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Sat, 11 May 2024 15:32:16 +0200 Subject: [PATCH 02/15] modify hooks to allow regex use for specifying public routes --- src/hooks.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e62952f2..945caea5 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,7 +4,7 @@ import { TOKEN_NAME, parseTokenToJwt } from 'services/auth/token' import type { UserAuthCredentials } from 'services/user/user' import { getUserAuthCredentials } from 'services/user/user-auth-service' -const PUBLIC_ROUTES = ['/', '/login', '/signup'] +const PUBLIC_ROUTES = [/^\/$/, /^\/login$/, /^\/signup$/, /^\/api\/*/] export const handle: Handle = async ({ event, resolve }) => { const { locals, cookies, url } = event @@ -25,7 +25,7 @@ export const handle: Handle = async ({ event, resolve }) => { locals.user = undefined } - if (PUBLIC_ROUTES.includes(pathname) || userAuthCredentials) { + if (userAuthCredentials || PUBLIC_ROUTES.some((it) => it.test(pathname))) { return await resolve(event) } From 84ae992a61acd559bf387499c47fcf3ec2f5b526 Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Wed, 15 May 2024 12:03:02 +0200 Subject: [PATCH 03/15] add schemas --- .husky/pre-commit | 2 +- src/routes/(api)/api/api.model.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/routes/(api)/api/api.model.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index dff836df..72c4429b 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm exec lint-staged \ No newline at end of file +npm test diff --git a/src/routes/(api)/api/api.model.ts b/src/routes/(api)/api/api.model.ts new file mode 100644 index 00000000..cccd571a --- /dev/null +++ b/src/routes/(api)/api/api.model.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' + +const translationKeySchema = z.string().brand('translation-key') +export type TranslationKey = z.infer + +const translationLanguageSchema = z.string().brand('translation-language') +export type TranslationLanguage = z.infer + +const translationValueSchema = z.string().brand('translation-value') +export type TranslationValue = z.infer + +const addTranslationCommandSchema = z.object({ + key: translationKeySchema, + lang: translationLanguageSchema, + value: translationValueSchema +}) +export type AddTranslationCommand = z.infer + +export const translationPOSTRequestSchema = addTranslationCommandSchema.array() + +export const translationsDELETERequestSchema = translationKeySchema.array() + +const frontendAdapterSchema = z.enum(['typesafe-i18n', 'other']) + +export const projectConfigPOSTRequestSchema = z.object({ + frontendAdapter: frontendAdapterSchema +}) From 86f6077a7447763f48aa81ccf6354085f3a46954 Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Wed, 15 May 2024 12:56:32 +0200 Subject: [PATCH 04/15] start rudimentary api key validation --- services/src/auth/api-key.ts | 4 ++++ src/lib/server/request-utils.ts | 11 +++++++++++ .../[project]/[lang]/translations/+server.ts | 12 +++++++++--- .../(api)/api/[project]/config/+server.ts | 12 ++++++++++-- src/routes/(api)/api/api-utils.ts | 17 +++++++++++++++++ src/routes/(api)/api/api.model.ts | 7 +++---- 6 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 services/src/auth/api-key.ts create mode 100644 src/lib/server/request-utils.ts create mode 100644 src/routes/(api)/api/api-utils.ts diff --git a/services/src/auth/api-key.ts b/services/src/auth/api-key.ts new file mode 100644 index 00000000..3ce22aa5 --- /dev/null +++ b/services/src/auth/api-key.ts @@ -0,0 +1,4 @@ +export const checkApiKeyAccess = async (apiKey: string, project: string): Promise => { + //TODO implement + return Promise.resolve(false) +} diff --git a/src/lib/server/request-utils.ts b/src/lib/server/request-utils.ts new file mode 100644 index 00000000..04041169 --- /dev/null +++ b/src/lib/server/request-utils.ts @@ -0,0 +1,11 @@ +import { error } from '@sveltejs/kit' +import type { z } from 'zod' + +export const validateRequestBody = async (req: Request, schema: z.ZodSchema): Promise => { + const body = await req.json() + const validationResult = schema.safeParse(body) + if (!validationResult.success) { + error(400, 'Invalid request') + } + return validationResult.data +} diff --git a/src/routes/(api)/api/[project]/[lang]/translations/+server.ts b/src/routes/(api)/api/[project]/[lang]/translations/+server.ts index 77bf4fc7..eed50968 100644 --- a/src/routes/(api)/api/[project]/[lang]/translations/+server.ts +++ b/src/routes/(api)/api/[project]/[lang]/translations/+server.ts @@ -1,12 +1,18 @@ +import { validateRequestBody } from '$lib/server/request-utils' +import { authorize } from '../../../api-utils' +import { translationPOSTRequestSchema, type ProjectId } from '../../../api.model' import type { RequestHandler } from './$types' -export const POST: RequestHandler = ({ params }) => { +export const POST: RequestHandler = async ({ params, request }) => { + authorize(request, params.project as ProjectId) + const newTranslations = validateRequestBody(request, translationPOSTRequestSchema) return new Response( - `getting POST for translations on project "${params.project}" and lang "${params.lang}"` + `getting POST for translations on project "${params.project}" and lang "${params.lang}" with body "${JSON.stringify(newTranslations)}"` ) } -export const GET: RequestHandler = ({ params }) => { +export const GET: RequestHandler = ({ params, request }) => { + authorize(request, params.project as ProjectId) return new Response( `getting GET for translations on project "${params.project}" and lang "${params.lang}"` ) diff --git a/src/routes/(api)/api/[project]/config/+server.ts b/src/routes/(api)/api/[project]/config/+server.ts index f04960dd..65736b8c 100644 --- a/src/routes/(api)/api/[project]/config/+server.ts +++ b/src/routes/(api)/api/[project]/config/+server.ts @@ -1,5 +1,13 @@ +import { validateRequestBody } from '$lib/server/request-utils' +import { authorize } from '../../api-utils' +import { projectConfigPOSTRequestSchema, type ProjectId } from '../../api.model' import type { RequestHandler } from './$types' -export const POST: RequestHandler = ({ params }) => { - return new Response(`getting post for config on project "${params.project}"`) +export const POST: RequestHandler = async ({ params, request }) => { + authorize(request, params.project as ProjectId) + const config = await validateRequestBody(request, projectConfigPOSTRequestSchema) + + return new Response( + `getting post for config on project "${params.project}" with payload "${JSON.stringify(config)}"` + ) } diff --git a/src/routes/(api)/api/api-utils.ts b/src/routes/(api)/api/api-utils.ts new file mode 100644 index 00000000..deca3eec --- /dev/null +++ b/src/routes/(api)/api/api-utils.ts @@ -0,0 +1,17 @@ +import { checkApiKeyAccess } from 'services/auth/api-key' +import type { ProjectId } from './api.model' +import { error } from '@sveltejs/kit' + +export const authorize = async (req: Request, project: ProjectId) => { + const apiKey = req.headers.get('Authorization') + if (!apiKey) { + error(401, 'No API key provided in the Authorization header') + } + const hasAccess = await checkApiKeyAccess(apiKey, project) + if (!hasAccess) { + error( + 403, + 'The provided API key is invalid, the project does not exist, or the provided key does not grant access to the project' + ) + } +} diff --git a/src/routes/(api)/api/api.model.ts b/src/routes/(api)/api/api.model.ts index cccd571a..f2f9ce75 100644 --- a/src/routes/(api)/api/api.model.ts +++ b/src/routes/(api)/api/api.model.ts @@ -3,15 +3,14 @@ import { z } from 'zod' const translationKeySchema = z.string().brand('translation-key') export type TranslationKey = z.infer -const translationLanguageSchema = z.string().brand('translation-language') -export type TranslationLanguage = z.infer - const translationValueSchema = z.string().brand('translation-value') export type TranslationValue = z.infer +const projectIdSchema = z.string().brand('project-id') +export type ProjectId = z.infer + const addTranslationCommandSchema = z.object({ key: translationKeySchema, - lang: translationLanguageSchema, value: translationValueSchema }) export type AddTranslationCommand = z.infer From be590f91836b5b59704eae81278ab32c072d2064 Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Thu, 16 May 2024 19:27:39 +0200 Subject: [PATCH 05/15] add migration --- .../migrations/2024-05-16T19:00:00Z_apikey.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts diff --git a/services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts b/services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts new file mode 100644 index 00000000..2eacd98b --- /dev/null +++ b/services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts @@ -0,0 +1,15 @@ +import { Kysely } from 'kysely' +import { createTableMigration } from '../migration.util' + +export async function up(db: Kysely): Promise { + await createTableMigration(db, 'apikeys') + .addColumn('key', 'text', (col) => col.unique().notNull()) + .addColumn('project_id', 'integer', (col) => + col.references('projects.id').onDelete('cascade').notNull() + ) + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('apikeys').execute() +} From b63121f1758044a944a7fa86b809a6a0ea6bdb60 Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Thu, 16 May 2024 20:22:11 +0200 Subject: [PATCH 06/15] add apikey repository and services --- services/src/auth/api-key.repository.ts | 27 +++++++++++++++++++++++++ services/src/auth/api-key.service.ts | 5 +++++ services/src/auth/api-key.ts | 9 +++++---- src/routes/(api)/api/api-utils.ts | 2 +- src/routes/(api)/api/api.model.ts | 2 +- 5 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 services/src/auth/api-key.repository.ts create mode 100644 services/src/auth/api-key.service.ts diff --git a/services/src/auth/api-key.repository.ts b/services/src/auth/api-key.repository.ts new file mode 100644 index 00000000..50f27306 --- /dev/null +++ b/services/src/auth/api-key.repository.ts @@ -0,0 +1,27 @@ +import { db } from '../db/database' +import type { ApiKeyCreationParams, SelectableApiKey } from './api-key' + +export function createApiKey(projectId: number): Promise { + const insertKey: ApiKeyCreationParams = { + key: 'something', + project_id: projectId + } + return db + .insertInto('apikeys') + .values(insertKey) + .returningAll() + .executeTakeFirstOrThrow(() => new Error('Error creating Api Access Key')) +} + +export function getApiKeysForProject(projectId: number): Promise { + return db.selectFrom('apikeys').selectAll().where('project_id', '==', projectId).execute() +} + +export async function projectHasKey(projectId: number, key: string): Promise { + const result = await db + .selectFrom('apikeys') + .where('project_id', '==', projectId) + .where('key', '==', key) + .execute() + return !!result.length +} diff --git a/services/src/auth/api-key.service.ts b/services/src/auth/api-key.service.ts new file mode 100644 index 00000000..28fdaf8e --- /dev/null +++ b/services/src/auth/api-key.service.ts @@ -0,0 +1,5 @@ +import { projectHasKey } from './api-key.repository' + +export const checkApiKeyAccess = async (apiKey: string, projectId: number): Promise => { + return await projectHasKey(projectId, apiKey) +} diff --git a/services/src/auth/api-key.ts b/services/src/auth/api-key.ts index 3ce22aa5..0665bf0e 100644 --- a/services/src/auth/api-key.ts +++ b/services/src/auth/api-key.ts @@ -1,4 +1,5 @@ -export const checkApiKeyAccess = async (apiKey: string, project: string): Promise => { - //TODO implement - return Promise.resolve(false) -} +import type { Insertable, Selectable } from 'kysely' +import type { Apikeys } from 'kysely-codegen' + +export type ApiKeyCreationParams = Insertable> +export type SelectableApiKey = Selectable diff --git a/src/routes/(api)/api/api-utils.ts b/src/routes/(api)/api/api-utils.ts index deca3eec..7292bfee 100644 --- a/src/routes/(api)/api/api-utils.ts +++ b/src/routes/(api)/api/api-utils.ts @@ -1,4 +1,4 @@ -import { checkApiKeyAccess } from 'services/auth/api-key' +import { checkApiKeyAccess } from 'services/auth/api-key.service' import type { ProjectId } from './api.model' import { error } from '@sveltejs/kit' diff --git a/src/routes/(api)/api/api.model.ts b/src/routes/(api)/api/api.model.ts index f2f9ce75..981ead11 100644 --- a/src/routes/(api)/api/api.model.ts +++ b/src/routes/(api)/api/api.model.ts @@ -6,7 +6,7 @@ export type TranslationKey = z.infer const translationValueSchema = z.string().brand('translation-value') export type TranslationValue = z.infer -const projectIdSchema = z.string().brand('project-id') +const projectIdSchema = z.number().brand('project-id') export type ProjectId = z.infer const addTranslationCommandSchema = z.object({ From b0530cfd40f52eccf4849c6354e3db60e54bcd82 Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Thu, 30 May 2024 14:57:57 +0200 Subject: [PATCH 07/15] add repository for project --- .../migrations/2024-04-28T09:42:38Z_init.ts | 2 +- .../project-repository.integration.test.ts | 52 +++++++++++++++++++ services/src/project/project.repository.ts | 24 +++++++++ services/src/project/project.ts | 7 +++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 services/src/project/project-repository.integration.test.ts create mode 100644 services/src/project/project.repository.ts create mode 100644 services/src/project/project.ts diff --git a/services/src/kysely/migrations/2024-04-28T09:42:38Z_init.ts b/services/src/kysely/migrations/2024-04-28T09:42:38Z_init.ts index 50f2a5a3..77d74b1e 100644 --- a/services/src/kysely/migrations/2024-04-28T09:42:38Z_init.ts +++ b/services/src/kysely/migrations/2024-04-28T09:42:38Z_init.ts @@ -11,7 +11,7 @@ export async function up(db: Kysely): Promise { await createTableMigration(db, 'projects') .addColumn('name', 'text', (col) => col.unique().notNull()) .addColumn('base_language', 'integer', (col) => - col.references('languages.id').onDelete('restrict').notNull() + col.references('languages.id').onDelete('restrict') ) .execute() diff --git a/services/src/project/project-repository.integration.test.ts b/services/src/project/project-repository.integration.test.ts new file mode 100644 index 00000000..bbb2690c --- /dev/null +++ b/services/src/project/project-repository.integration.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { PojectCreationParams, SelectableProject } from './project' +import { db } from '../db/database' +import { runMigration } from '../db/database-migration-util' +import { createProject, deleteProjectById, getProjectById } from './project.repository' + +const projectCreationObject: PojectCreationParams = { + name: 'some project name' +} + +beforeEach(async () => { + db.reset() + await runMigration() +}) + +describe('Project Repository', () => { + describe('createProject', () => { + it('should create a project with the correct attributes', async () => { + await createProject(projectCreationObject) + + const projects = await db.selectFrom('projects').selectAll().execute() + expect(projects).toHaveLength(1) + + const project = projects[0] as SelectableProject + + expect(project).toMatchObject(projectCreationObject) + expect(project.id).toBeTypeOf('number') + }) + }) + + describe('getProjectById', () => { + it('should get a created project with its ID', async () => { + const createdProject = await createProject(projectCreationObject) + + const retrievedProject = await getProjectById(createdProject.id) + + expect(retrievedProject.id).toBe(createdProject.id) + }) + }) + + describe('deleteProjectById', () => { + it('should delete a project based on its ID', async () => { + const createdProject = await createProject(projectCreationObject) + + const retrievedProject = await getProjectById(createdProject.id) + expect(retrievedProject).toBeTruthy() + + await deleteProjectById(createdProject.id) + expect(() => getProjectById(createdProject.id)).rejects.toThrowError() + }) + }) +}) diff --git a/services/src/project/project.repository.ts b/services/src/project/project.repository.ts new file mode 100644 index 00000000..ff403b2b --- /dev/null +++ b/services/src/project/project.repository.ts @@ -0,0 +1,24 @@ +import { db } from '../db/database' +import type { PojectCreationParams } from './project' + +export function createProject(projectParams: PojectCreationParams) { + return db + .insertInto('projects') + .values({ + ...projectParams + }) + .returningAll() + .executeTakeFirstOrThrow(() => new Error('Error creating Project')) +} + +export function getProjectById(projectId: number) { + return db + .selectFrom('projects') + .selectAll() + .where('projects.id', '==', projectId) + .executeTakeFirstOrThrow(() => new Error(`Could not find Project with ID "${projectId}"`)) +} + +export function deleteProjectById(projectId: number) { + return db.deleteFrom('projects').where('projects.id', '==', projectId).execute() +} diff --git a/services/src/project/project.ts b/services/src/project/project.ts new file mode 100644 index 00000000..ffff9cd4 --- /dev/null +++ b/services/src/project/project.ts @@ -0,0 +1,7 @@ +import type { Insertable, Selectable } from 'kysely' +import type { Projects } from 'kysely-codegen' + +export type PojectCreationParams = Insertable< + Omit +> +export type SelectableProject = Selectable From b339317e768d7aa4bf567b48bd4d207f39b8d67c Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Thu, 30 May 2024 17:49:29 +0200 Subject: [PATCH 08/15] add project repo and tests --- pnpm-lock.yaml | 19 +--- services/package.json | 2 + .../api-key-repository.integration.test.ts | 90 +++++++++++++++++++ services/src/auth/api-key-service.test.ts | 0 services/src/auth/api-key.repository.ts | 43 +++++---- 5 files changed, 120 insertions(+), 34 deletions(-) create mode 100644 services/src/auth/api-key-repository.integration.test.ts create mode 100644 services/src/auth/api-key-service.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf59e798..e570e28a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2134,10 +2134,6 @@ packages: resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} engines: {node: 14 || >=16.14} - lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - lucide-svelte@0.378.0: resolution: {integrity: sha512-T7hV1sfOc94AWE5GOJ6r9wGEsR4h4TJr8d4Z0sM8O0e3IBcmeIvEGRAA6jCp7NGy4PeGrn5Tju6Y2JwJQntNrQ==} peerDependencies: @@ -2797,11 +2793,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.6.0: - resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} - engines: {node: '>=10'} - hasBin: true - semver@7.6.2: resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} engines: {node: '>=10'} @@ -5340,7 +5331,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.2 - semver: 7.6.0 + semver: 7.6.2 just-clone@6.2.0: {} @@ -5455,10 +5446,6 @@ snapshots: lru-cache@10.2.2: {} - lru-cache@6.0.0: - dependencies: - yallist: 4.0.0 - lucide-svelte@0.378.0(svelte@4.2.17): dependencies: svelte: 4.2.17 @@ -6063,10 +6050,6 @@ snapshots: semver@6.3.1: {} - semver@7.6.0: - dependencies: - lru-cache: 6.0.0 - semver@7.6.2: {} set-blocking@2.0.0: {} diff --git a/services/package.json b/services/package.json index 87afd26a..22c12e98 100644 --- a/services/package.json +++ b/services/package.json @@ -7,9 +7,11 @@ "kysely": "^0.27.3", "pino": "^9.0.0", "pino-pretty": "^11.0.0", + "uuid": "^9.0.1", "zod": "^3.23.5" }, "devDependencies": { + "@types/uuid": "^9.0.8", "vite": "^5.2.11", "vitest": "^1.5.3" }, diff --git a/services/src/auth/api-key-repository.integration.test.ts b/services/src/auth/api-key-repository.integration.test.ts new file mode 100644 index 00000000..45f45da6 --- /dev/null +++ b/services/src/auth/api-key-repository.integration.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { db } from '../db/database' +import { runMigration } from '../db/database-migration-util' +import { createProject } from '../project/project.repository' +import type { ProjectId } from '../../../src/routes/(api)/api/api.model' +import { + createApiKey, + deleteApiKey, + getApiKeysForProject, + projectHasKey +} from './api-key.repository' + +beforeEach(async () => { + db.reset() + await runMigration() +}) + +describe('ApiKey Repository', () => { + let projectId: ProjectId + beforeEach(async () => { + projectId = (await createProject({ name: 'demo project name' })).id as ProjectId + }) + + describe('createApiKey', () => { + it('should create an api key given an existing project', async () => { + const result = await createApiKey(projectId) + expect(result).toBeTruthy() + expect(false).toBeFalsy() + }) + }) + + describe('getApiKeysForProject', () => { + it('should fetch all apiKeys for a given project', async () => { + const key1 = await createApiKey(projectId) + const key2 = await createApiKey(projectId) + + const result = (await getApiKeysForProject(projectId)).map((it) => it.key) + expect(result).toHaveLength(2) + expect(result).includes(key1.key) + expect(result).includes(key2.key) + }) + + it('should no longer find deleted keys', async () => { + const key = await createApiKey(projectId) + + const keysBeforeDelete = await getApiKeysForProject(projectId) + expect(keysBeforeDelete.map((it) => it.key)).toContain(key.key) + + await deleteApiKey(projectId, key.key) + const keysAfterDelete = await getApiKeysForProject(projectId) + expect(keysAfterDelete.map((it) => it.key).includes(key.key)).toBe(false) + }) + + it('should return an empty list in case the project does not exist', async () => { + const keys = await getApiKeysForProject(projectId) + expect(keys).toHaveLength(0) + }) + + it('should return an empty list if there are no keys', async () => { + const result = await getApiKeysForProject(projectId) + expect(result).toHaveLength(0) + }) + }) + + describe('projectHasKey', () => { + it('should return true if there is a key for the project', async () => { + const key = await createApiKey(projectId) + const result = await projectHasKey(projectId, key.key) + expect(result).toBe(true) + }) + + it('should return false if the project does not have the corresponding key', async () => { + const result = await projectHasKey(projectId, 'nonexiststant-id') + expect(result).toBe(false) + }) + + it('should return false if the project does not exist', async () => { + const result = await projectHasKey(4242, 'nonexiststant-id') + expect(result).toBe(false) + }) + + it('should return false if key and project exist, but do not match', async () => { + const otherProjectId = (await createProject({ name: 'another Project' })).id as ProjectId + const key = await createApiKey(projectId) + + const result = await projectHasKey(otherProjectId, key.key) + expect(result).toBe(false) + }) + }) +}) diff --git a/services/src/auth/api-key-service.test.ts b/services/src/auth/api-key-service.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/services/src/auth/api-key.repository.ts b/services/src/auth/api-key.repository.ts index 50f27306..233e74bf 100644 --- a/services/src/auth/api-key.repository.ts +++ b/services/src/auth/api-key.repository.ts @@ -1,27 +1,38 @@ import { db } from '../db/database' import type { ApiKeyCreationParams, SelectableApiKey } from './api-key' +import { v4 as uuid } from 'uuid' export function createApiKey(projectId: number): Promise { - const insertKey: ApiKeyCreationParams = { - key: 'something', - project_id: projectId - } - return db - .insertInto('apikeys') - .values(insertKey) - .returningAll() - .executeTakeFirstOrThrow(() => new Error('Error creating Api Access Key')) + const insertKey: ApiKeyCreationParams = { + key: uuid(), + project_id: projectId + } + return db + .insertInto('apikeys') + .values(insertKey) + .returningAll() + .executeTakeFirstOrThrow(() => new Error('Error creating Api Access Key')) } export function getApiKeysForProject(projectId: number): Promise { - return db.selectFrom('apikeys').selectAll().where('project_id', '==', projectId).execute() + return db.selectFrom('apikeys').selectAll().where('project_id', '==', projectId).execute() } export async function projectHasKey(projectId: number, key: string): Promise { - const result = await db - .selectFrom('apikeys') - .where('project_id', '==', projectId) - .where('key', '==', key) - .execute() - return !!result.length + const result = await db + .selectFrom('apikeys') + .selectAll() + .where('project_id', '==', projectId) + .where('key', '==', key) + .execute() + + return !!result.length +} + +export async function deleteApiKey(projectId: number, key: string): Promise { + await db + .deleteFrom('apikeys') + .where('project_id', '==', projectId) + .where('key', '==', key) + .execute() } From b0b9d906583e0f000118a650b35631ce5e3506f3 Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Fri, 31 May 2024 12:10:01 +0200 Subject: [PATCH 09/15] add name to apiKey --- package.json | 2 +- .../api-key-repository.integration.test.ts | 46 ++++++++++++++++++- services/src/auth/api-key.repository.ts | 16 ++++++- .../migrations/2024-05-16T19:00:00Z_apikey.ts | 1 + 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 2de24072..0b7b6ebc 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test:integration": "cross-env DATABASE_LOCATION=:memory: vitest run --project integration", "test:e2e": "playwright test", "---- DB ------------------------------------------------------------": "", - "setupdb": "pnpm migrate && pnpm db-types", + "setupdb": "pnpm migrate:latest && pnpm db-types", "migrate": "tsx ./services/src/kysely/migrator.ts", "migrate:latest": "pnpm run migrate -- latest", "migrate:up": "pnpm run migrate -- up", diff --git a/services/src/auth/api-key-repository.integration.test.ts b/services/src/auth/api-key-repository.integration.test.ts index 45f45da6..d8059f6a 100644 --- a/services/src/auth/api-key-repository.integration.test.ts +++ b/services/src/auth/api-key-repository.integration.test.ts @@ -7,7 +7,8 @@ import { createApiKey, deleteApiKey, getApiKeysForProject, - projectHasKey + projectHasKey, + setApiKeyName } from './api-key.repository' beforeEach(async () => { @@ -40,6 +41,14 @@ describe('ApiKey Repository', () => { expect(result).includes(key2.key) }) + it('should retrieve the name of the apiKey when it is created with one', async () => { + const name = 'some key name' + const key = await createApiKey(projectId, name) + const result = await getApiKeysForProject(projectId) + + expect(result.find((it) => it.key === key.key)?.name).toBe(name) + }) + it('should no longer find deleted keys', async () => { const key = await createApiKey(projectId) @@ -62,6 +71,41 @@ describe('ApiKey Repository', () => { }) }) + describe('setApiKeyName', () => { + it('should be able to set a name when previously there was none', async () => { + const key = await createApiKey(projectId) + const initialRetrieval = await getApiKeysForProject(projectId).then((it) => + it.find((apiKey) => apiKey.key === key.key) + ) + + expect(initialRetrieval?.name).toBeFalsy() + const updatedName = 'some new apiKeyName' + await setApiKeyName(key.id, updatedName) + + const secondRetrieval = await getApiKeysForProject(projectId).then((it) => + it.find((apiKey) => apiKey.key === key.key) + ) + expect(secondRetrieval?.name).toBe(updatedName) + }) + + it('should be able to set the name', async () => { + const initialName = 'my personal api key' + const key = await createApiKey(projectId, initialName) + const initialRetrieval = await getApiKeysForProject(projectId).then((it) => + it.find((apiKey) => apiKey.key === key.key) + ) + + expect(initialRetrieval?.name).toBe(initialName) + const updatedName = 'some new apiKeyName' + await setApiKeyName(key.id, updatedName) + + const secondRetrieval = await getApiKeysForProject(projectId).then((it) => + it.find((apiKey) => apiKey.key === key.key) + ) + expect(secondRetrieval?.name).toBe(updatedName) + }) + }) + describe('projectHasKey', () => { it('should return true if there is a key for the project', async () => { const key = await createApiKey(projectId) diff --git a/services/src/auth/api-key.repository.ts b/services/src/auth/api-key.repository.ts index 233e74bf..ddc31611 100644 --- a/services/src/auth/api-key.repository.ts +++ b/services/src/auth/api-key.repository.ts @@ -2,9 +2,13 @@ import { db } from '../db/database' import type { ApiKeyCreationParams, SelectableApiKey } from './api-key' import { v4 as uuid } from 'uuid' -export function createApiKey(projectId: number): Promise { +export function createApiKey( + projectId: number, + name: string | null = null +): Promise { const insertKey: ApiKeyCreationParams = { key: uuid(), + name, project_id: projectId } return db @@ -18,6 +22,16 @@ export function getApiKeysForProject(projectId: number): Promise { + await db + .updateTable('apikeys') + .set({ + name + }) + .where('id', '==', keyId) + .execute() +} + export async function projectHasKey(projectId: number, key: string): Promise { const result = await db .selectFrom('apikeys') diff --git a/services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts b/services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts index 2eacd98b..33039975 100644 --- a/services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts +++ b/services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts @@ -4,6 +4,7 @@ import { createTableMigration } from '../migration.util' export async function up(db: Kysely): Promise { await createTableMigration(db, 'apikeys') .addColumn('key', 'text', (col) => col.unique().notNull()) + .addColumn('name', 'text') .addColumn('project_id', 'integer', (col) => col.references('projects.id').onDelete('cascade').notNull() ) From 1c72b190193ce56ee83c6f58e4b93b3576ed52f9 Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Fri, 31 May 2024 14:59:26 +0200 Subject: [PATCH 10/15] continue --- services/src/auth/api-key.service.ts | 21 ++++++++++++++++++++- services/src/auth/api-key.ts | 21 +++++++++++++++++++++ services/src/project/project.ts | 13 +++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/services/src/auth/api-key.service.ts b/services/src/auth/api-key.service.ts index 28fdaf8e..442bcd18 100644 --- a/services/src/auth/api-key.service.ts +++ b/services/src/auth/api-key.service.ts @@ -1,5 +1,24 @@ -import { projectHasKey } from './api-key.repository' +import { + createApiKey, + getApiKeysForProject, + projectHasKey, + setApiKeyName +} from './api-key.repository' export const checkApiKeyAccess = async (apiKey: string, projectId: number): Promise => { return await projectHasKey(projectId, apiKey) } + +export const addApiKey = async (projectId: number): Promise => { + const key = await createApiKey(projectId) + return key.key +} + +export const changeApiKeyName = async (apiKeyId: number, name: string) => { + await setApiKeyName(apiKeyId, name) +} + +export const listApiKeys = async (projectId: number): Promise => { + const result = await getApiKeysForProject(projectId) + return result.map((it) => it.key) +} diff --git a/services/src/auth/api-key.ts b/services/src/auth/api-key.ts index 0665bf0e..bb79fc13 100644 --- a/services/src/auth/api-key.ts +++ b/services/src/auth/api-key.ts @@ -1,5 +1,26 @@ import type { Insertable, Selectable } from 'kysely' import type { Apikeys } from 'kysely-codegen' +import { z } from 'zod' export type ApiKeyCreationParams = Insertable> export type SelectableApiKey = Selectable + +const apiKeyIdSchema = z.number().brand('apiKeyId') +export type ApiKeyId = z.infer + +const apiKeyKeySchema = z.string().uuid().brand('apiKeyKey') +export type ApiKeyKey = z.infer + +const apiKeySchema = z.object({ + id: apiKeyIdSchema, + key: apiKeyKeySchema, + name: z.string(), + project_id: z.number(), + created_at: z.date(), + updated_at: z.date() +}) +export type ApiKey = z.infer + +const frontendApiKeySchema = apiKeySchema.omit({ + id: true +}) diff --git a/services/src/project/project.ts b/services/src/project/project.ts index ffff9cd4..50c2d019 100644 --- a/services/src/project/project.ts +++ b/services/src/project/project.ts @@ -1,7 +1,20 @@ import type { Insertable, Selectable } from 'kysely' import type { Projects } from 'kysely-codegen' +import { z } from 'zod' export type PojectCreationParams = Insertable< Omit > export type SelectableProject = Selectable + +const projectIdSchema = z.number().brand('projectId') +export type ProjectId = z.infer + +const projectSchema = z.object({ + id: projectIdSchema, + created_at: z.string(), + updated_at: z.string(), + name: z.string(), + base_language: z.number().nullable() +}) +export type Project = z.infer From 1a8ed132f6a2dc26bf134f6f1d389226af1a81aa Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Sun, 2 Jun 2024 13:53:18 +0200 Subject: [PATCH 11/15] rename apiKey to ApiAccess --- ...api-access-repository.integration.test.ts} | 86 +++++++++---------- ...ice.test.ts => api-access-service.test.ts} | 0 ...repository.ts => api-access.repository.ts} | 28 +++--- services/src/auth/api-access.service.ts | 24 ++++++ services/src/auth/api-access.ts | 22 +++++ services/src/auth/api-key.service.ts | 24 ------ services/src/auth/api-key.ts | 26 ------ .../migrations/2024-05-16T19:00:00Z_apikey.ts | 6 +- .../[project]/[lang]/translations/+server.ts | 4 +- .../(api)/api/[project]/config/+server.ts | 2 +- src/routes/(api)/api/api-utils.ts | 2 +- 11 files changed, 110 insertions(+), 114 deletions(-) rename services/src/auth/{api-key-repository.integration.test.ts => api-access-repository.integration.test.ts} (51%) rename services/src/auth/{api-key-service.test.ts => api-access-service.test.ts} (100%) rename services/src/auth/{api-key.repository.ts => api-access.repository.ts} (50%) create mode 100644 services/src/auth/api-access.service.ts create mode 100644 services/src/auth/api-access.ts delete mode 100644 services/src/auth/api-key.service.ts delete mode 100644 services/src/auth/api-key.ts diff --git a/services/src/auth/api-key-repository.integration.test.ts b/services/src/auth/api-access-repository.integration.test.ts similarity index 51% rename from services/src/auth/api-key-repository.integration.test.ts rename to services/src/auth/api-access-repository.integration.test.ts index d8059f6a..e5ff99b8 100644 --- a/services/src/auth/api-key-repository.integration.test.ts +++ b/services/src/auth/api-access-repository.integration.test.ts @@ -2,14 +2,14 @@ import { beforeEach, describe, expect, it } from 'vitest' import { db } from '../db/database' import { runMigration } from '../db/database-migration-util' import { createProject } from '../project/project.repository' -import type { ProjectId } from '../../../src/routes/(api)/api/api.model' import { - createApiKey, - deleteApiKey, - getApiKeysForProject, + createApiAccess, + deleteApiAccess, + getApiAccessForProject, projectHasKey, - setApiKeyName -} from './api-key.repository' + setApiAccessName +} from './api-access.repository' +import type { ProjectId } from '../project/project' beforeEach(async () => { db.reset() @@ -22,85 +22,85 @@ describe('ApiKey Repository', () => { projectId = (await createProject({ name: 'demo project name' })).id as ProjectId }) - describe('createApiKey', () => { + describe('createApiAccess', () => { it('should create an api key given an existing project', async () => { - const result = await createApiKey(projectId) + const result = await createApiAccess(projectId) expect(result).toBeTruthy() expect(false).toBeFalsy() }) }) - describe('getApiKeysForProject', () => { + describe('getApiAccessForProject', () => { it('should fetch all apiKeys for a given project', async () => { - const key1 = await createApiKey(projectId) - const key2 = await createApiKey(projectId) + const access1 = await createApiAccess(projectId) + const access2 = await createApiAccess(projectId) - const result = (await getApiKeysForProject(projectId)).map((it) => it.key) - expect(result).toHaveLength(2) - expect(result).includes(key1.key) - expect(result).includes(key2.key) + const keys = (await getApiAccessForProject(projectId)).map((it) => it.apikey) + expect(keys).toHaveLength(2) + expect(keys).includes(access1.apikey) + expect(keys).includes(access2.apikey) }) it('should retrieve the name of the apiKey when it is created with one', async () => { const name = 'some key name' - const key = await createApiKey(projectId, name) - const result = await getApiKeysForProject(projectId) + const key = await createApiAccess(projectId, name) + const result = await getApiAccessForProject(projectId) - expect(result.find((it) => it.key === key.key)?.name).toBe(name) + expect(result.find((it) => it.apikey === key.apikey)?.name).toBe(name) }) it('should no longer find deleted keys', async () => { - const key = await createApiKey(projectId) + const key = await createApiAccess(projectId) - const keysBeforeDelete = await getApiKeysForProject(projectId) - expect(keysBeforeDelete.map((it) => it.key)).toContain(key.key) + const keysBeforeDelete = await getApiAccessForProject(projectId) + expect(keysBeforeDelete.map((it) => it.apikey)).toContain(key.apikey) - await deleteApiKey(projectId, key.key) - const keysAfterDelete = await getApiKeysForProject(projectId) - expect(keysAfterDelete.map((it) => it.key).includes(key.key)).toBe(false) + await deleteApiAccess(projectId, key.apikey) + const keysAfterDelete = await getApiAccessForProject(projectId) + expect(keysAfterDelete.map((it) => it.apikey).includes(key.apikey)).toBe(false) }) it('should return an empty list in case the project does not exist', async () => { - const keys = await getApiKeysForProject(projectId) + const keys = await getApiAccessForProject(projectId) expect(keys).toHaveLength(0) }) it('should return an empty list if there are no keys', async () => { - const result = await getApiKeysForProject(projectId) + const result = await getApiAccessForProject(projectId) expect(result).toHaveLength(0) }) }) - describe('setApiKeyName', () => { + describe('setApiAccessName', () => { it('should be able to set a name when previously there was none', async () => { - const key = await createApiKey(projectId) - const initialRetrieval = await getApiKeysForProject(projectId).then((it) => - it.find((apiKey) => apiKey.key === key.key) + const key = await createApiAccess(projectId) + const initialRetrieval = await getApiAccessForProject(projectId).then((it) => + it.find((apiKey) => apiKey.apikey === key.apikey) ) expect(initialRetrieval?.name).toBeFalsy() const updatedName = 'some new apiKeyName' - await setApiKeyName(key.id, updatedName) + await setApiAccessName(key.id, updatedName) - const secondRetrieval = await getApiKeysForProject(projectId).then((it) => - it.find((apiKey) => apiKey.key === key.key) + const secondRetrieval = await getApiAccessForProject(projectId).then((it) => + it.find((apiKey) => apiKey.apikey === key.apikey) ) expect(secondRetrieval?.name).toBe(updatedName) }) it('should be able to set the name', async () => { const initialName = 'my personal api key' - const key = await createApiKey(projectId, initialName) - const initialRetrieval = await getApiKeysForProject(projectId).then((it) => - it.find((apiKey) => apiKey.key === key.key) + const key = await createApiAccess(projectId, initialName) + const initialRetrieval = await getApiAccessForProject(projectId).then((it) => + it.find((apiKey) => apiKey.apikey === key.apikey) ) expect(initialRetrieval?.name).toBe(initialName) const updatedName = 'some new apiKeyName' - await setApiKeyName(key.id, updatedName) + await setApiAccessName(key.id, updatedName) - const secondRetrieval = await getApiKeysForProject(projectId).then((it) => - it.find((apiKey) => apiKey.key === key.key) + const secondRetrieval = await getApiAccessForProject(projectId).then((it) => + it.find((apiKey) => apiKey.apikey === key.apikey) ) expect(secondRetrieval?.name).toBe(updatedName) }) @@ -108,8 +108,8 @@ describe('ApiKey Repository', () => { describe('projectHasKey', () => { it('should return true if there is a key for the project', async () => { - const key = await createApiKey(projectId) - const result = await projectHasKey(projectId, key.key) + const key = await createApiAccess(projectId) + const result = await projectHasKey(projectId, key.apikey) expect(result).toBe(true) }) @@ -125,9 +125,9 @@ describe('ApiKey Repository', () => { it('should return false if key and project exist, but do not match', async () => { const otherProjectId = (await createProject({ name: 'another Project' })).id as ProjectId - const key = await createApiKey(projectId) + const key = await createApiAccess(projectId) - const result = await projectHasKey(otherProjectId, key.key) + const result = await projectHasKey(otherProjectId, key.apikey) expect(result).toBe(false) }) }) diff --git a/services/src/auth/api-key-service.test.ts b/services/src/auth/api-access-service.test.ts similarity index 100% rename from services/src/auth/api-key-service.test.ts rename to services/src/auth/api-access-service.test.ts diff --git a/services/src/auth/api-key.repository.ts b/services/src/auth/api-access.repository.ts similarity index 50% rename from services/src/auth/api-key.repository.ts rename to services/src/auth/api-access.repository.ts index ddc31611..595ec6cc 100644 --- a/services/src/auth/api-key.repository.ts +++ b/services/src/auth/api-access.repository.ts @@ -1,30 +1,30 @@ import { db } from '../db/database' -import type { ApiKeyCreationParams, SelectableApiKey } from './api-key' +import type { ApiKeyCreationParams, SelectableApiKey } from './api-access' import { v4 as uuid } from 'uuid' -export function createApiKey( +export function createApiAccess( projectId: number, name: string | null = null ): Promise { const insertKey: ApiKeyCreationParams = { - key: uuid(), + apikey: uuid(), name, project_id: projectId } return db - .insertInto('apikeys') + .insertInto('apiaccess') .values(insertKey) .returningAll() .executeTakeFirstOrThrow(() => new Error('Error creating Api Access Key')) } -export function getApiKeysForProject(projectId: number): Promise { - return db.selectFrom('apikeys').selectAll().where('project_id', '==', projectId).execute() +export function getApiAccessForProject(projectId: number): Promise { + return db.selectFrom('apiaccess').selectAll().where('project_id', '==', projectId).execute() } -export async function setApiKeyName(keyId: number, name: string): Promise { +export async function setApiAccessName(keyId: number, name: string): Promise { await db - .updateTable('apikeys') + .updateTable('apiaccess') .set({ name }) @@ -32,21 +32,21 @@ export async function setApiKeyName(keyId: number, name: string): Promise .execute() } -export async function projectHasKey(projectId: number, key: string): Promise { +export async function projectHasKey(projectId: number, apikey: string): Promise { const result = await db - .selectFrom('apikeys') + .selectFrom('apiaccess') .selectAll() .where('project_id', '==', projectId) - .where('key', '==', key) + .where('apikey', '==', apikey) .execute() return !!result.length } -export async function deleteApiKey(projectId: number, key: string): Promise { +export async function deleteApiAccess(projectId: number, apikey: string): Promise { await db - .deleteFrom('apikeys') + .deleteFrom('apiaccess') .where('project_id', '==', projectId) - .where('key', '==', key) + .where('apikey', '==', apikey) .execute() } diff --git a/services/src/auth/api-access.service.ts b/services/src/auth/api-access.service.ts new file mode 100644 index 00000000..488509d4 --- /dev/null +++ b/services/src/auth/api-access.service.ts @@ -0,0 +1,24 @@ +import { + createApiAccess, + getApiAccessForProject, + projectHasKey, + setApiAccessName +} from './api-access.repository' + +export const checkApiKeyAccess = async (apiKey: string, projectId: number): Promise => { + return await projectHasKey(projectId, apiKey) +} + +export const addApiKey = async (projectId: number): Promise => { + const key = await createApiAccess(projectId) + return key.apikey +} + +export const changeApiKeyName = async (apiAccessId: number, name: string) => { + await setApiAccessName(apiAccessId, name) +} + +export const listApiKeys = async (projectId: number): Promise => { + const result = await getApiAccessForProject(projectId) + return result.map((it) => it.apikey) +} diff --git a/services/src/auth/api-access.ts b/services/src/auth/api-access.ts new file mode 100644 index 00000000..44113988 --- /dev/null +++ b/services/src/auth/api-access.ts @@ -0,0 +1,22 @@ +import type { Insertable, Selectable } from 'kysely' +import type { Apiaccess } from 'kysely-codegen' +import { z } from 'zod' + +export type ApiKeyCreationParams = Insertable> +export type SelectableApiKey = Selectable + +const apiKeyIdSchema = z.number().brand('api-access') +export type ApiKeyId = z.infer + +const apiKeyKeySchema = z.string().uuid().brand('api-key') +export type ApiKey = z.infer + +const apiAccessSchema = z.object({ + id: apiKeyIdSchema, + apikey: apiKeyKeySchema, + name: z.string(), + project_id: z.number(), + created_at: z.date(), + updated_at: z.date() +}) +export type ApiAccess = z.infer diff --git a/services/src/auth/api-key.service.ts b/services/src/auth/api-key.service.ts deleted file mode 100644 index 442bcd18..00000000 --- a/services/src/auth/api-key.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - createApiKey, - getApiKeysForProject, - projectHasKey, - setApiKeyName -} from './api-key.repository' - -export const checkApiKeyAccess = async (apiKey: string, projectId: number): Promise => { - return await projectHasKey(projectId, apiKey) -} - -export const addApiKey = async (projectId: number): Promise => { - const key = await createApiKey(projectId) - return key.key -} - -export const changeApiKeyName = async (apiKeyId: number, name: string) => { - await setApiKeyName(apiKeyId, name) -} - -export const listApiKeys = async (projectId: number): Promise => { - const result = await getApiKeysForProject(projectId) - return result.map((it) => it.key) -} diff --git a/services/src/auth/api-key.ts b/services/src/auth/api-key.ts deleted file mode 100644 index bb79fc13..00000000 --- a/services/src/auth/api-key.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Insertable, Selectable } from 'kysely' -import type { Apikeys } from 'kysely-codegen' -import { z } from 'zod' - -export type ApiKeyCreationParams = Insertable> -export type SelectableApiKey = Selectable - -const apiKeyIdSchema = z.number().brand('apiKeyId') -export type ApiKeyId = z.infer - -const apiKeyKeySchema = z.string().uuid().brand('apiKeyKey') -export type ApiKeyKey = z.infer - -const apiKeySchema = z.object({ - id: apiKeyIdSchema, - key: apiKeyKeySchema, - name: z.string(), - project_id: z.number(), - created_at: z.date(), - updated_at: z.date() -}) -export type ApiKey = z.infer - -const frontendApiKeySchema = apiKeySchema.omit({ - id: true -}) diff --git a/services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts b/services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts index 33039975..a11e4a2d 100644 --- a/services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts +++ b/services/src/kysely/migrations/2024-05-16T19:00:00Z_apikey.ts @@ -2,8 +2,8 @@ import { Kysely } from 'kysely' import { createTableMigration } from '../migration.util' export async function up(db: Kysely): Promise { - await createTableMigration(db, 'apikeys') - .addColumn('key', 'text', (col) => col.unique().notNull()) + await createTableMigration(db, 'apiaccess') + .addColumn('apikey', 'text', (col) => col.unique().notNull()) .addColumn('name', 'text') .addColumn('project_id', 'integer', (col) => col.references('projects.id').onDelete('cascade').notNull() @@ -12,5 +12,5 @@ export async function up(db: Kysely): Promise { } export async function down(db: Kysely): Promise { - await db.schema.dropTable('apikeys').execute() + await db.schema.dropTable('apiaccess').execute() } diff --git a/src/routes/(api)/api/[project]/[lang]/translations/+server.ts b/src/routes/(api)/api/[project]/[lang]/translations/+server.ts index eed50968..8474c2a6 100644 --- a/src/routes/(api)/api/[project]/[lang]/translations/+server.ts +++ b/src/routes/(api)/api/[project]/[lang]/translations/+server.ts @@ -4,7 +4,7 @@ import { translationPOSTRequestSchema, type ProjectId } from '../../../api.model import type { RequestHandler } from './$types' export const POST: RequestHandler = async ({ params, request }) => { - authorize(request, params.project as ProjectId) + authorize(request, +params.project as ProjectId) const newTranslations = validateRequestBody(request, translationPOSTRequestSchema) return new Response( `getting POST for translations on project "${params.project}" and lang "${params.lang}" with body "${JSON.stringify(newTranslations)}"` @@ -12,7 +12,7 @@ export const POST: RequestHandler = async ({ params, request }) => { } export const GET: RequestHandler = ({ params, request }) => { - authorize(request, params.project as ProjectId) + authorize(request, +params.project as ProjectId) return new Response( `getting GET for translations on project "${params.project}" and lang "${params.lang}"` ) diff --git a/src/routes/(api)/api/[project]/config/+server.ts b/src/routes/(api)/api/[project]/config/+server.ts index 65736b8c..9b9eeb20 100644 --- a/src/routes/(api)/api/[project]/config/+server.ts +++ b/src/routes/(api)/api/[project]/config/+server.ts @@ -4,7 +4,7 @@ import { projectConfigPOSTRequestSchema, type ProjectId } from '../../api.model' import type { RequestHandler } from './$types' export const POST: RequestHandler = async ({ params, request }) => { - authorize(request, params.project as ProjectId) + authorize(request, +params.project as ProjectId) const config = await validateRequestBody(request, projectConfigPOSTRequestSchema) return new Response( diff --git a/src/routes/(api)/api/api-utils.ts b/src/routes/(api)/api/api-utils.ts index 7292bfee..44d4d31d 100644 --- a/src/routes/(api)/api/api-utils.ts +++ b/src/routes/(api)/api/api-utils.ts @@ -1,4 +1,4 @@ -import { checkApiKeyAccess } from 'services/auth/api-key.service' +import { checkApiKeyAccess } from 'services/auth/api-access.service' import type { ProjectId } from './api.model' import { error } from '@sveltejs/kit' From 6643d8be9df4b178b62e4f3eb8e08b0890a37a7f Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Sun, 2 Jun 2024 14:16:18 +0200 Subject: [PATCH 12/15] fix schema issues --- services/src/auth/api-access-service.test.ts | 0 services/src/auth/api-access.service.ts | 19 ++++++++++--------- services/src/auth/api-access.ts | 8 ++++---- 3 files changed, 14 insertions(+), 13 deletions(-) delete mode 100644 services/src/auth/api-access-service.test.ts diff --git a/services/src/auth/api-access-service.test.ts b/services/src/auth/api-access-service.test.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/services/src/auth/api-access.service.ts b/services/src/auth/api-access.service.ts index 488509d4..b3217cb3 100644 --- a/services/src/auth/api-access.service.ts +++ b/services/src/auth/api-access.service.ts @@ -1,3 +1,5 @@ +import type { ProjectId } from '../project/project' +import { apiAccessSchema, type ApiAccess, type ApiAccessId, type ApiKey } from './api-access' import { createApiAccess, getApiAccessForProject, @@ -5,20 +7,19 @@ import { setApiAccessName } from './api-access.repository' -export const checkApiKeyAccess = async (apiKey: string, projectId: number): Promise => { - return await projectHasKey(projectId, apiKey) -} +export const checkApiKeyAccess = async (apiKey: ApiKey, projectId: ProjectId): Promise => + projectHasKey(projectId, apiKey) -export const addApiKey = async (projectId: number): Promise => { +export const addApiAccess = async (projectId: ProjectId): Promise => { const key = await createApiAccess(projectId) - return key.apikey + return apiAccessSchema.parse(key) } -export const changeApiKeyName = async (apiAccessId: number, name: string) => { +export const changeApiAccessName = async (apiAccessId: ApiAccessId, name: string) => { await setApiAccessName(apiAccessId, name) } -export const listApiKeys = async (projectId: number): Promise => { - const result = await getApiAccessForProject(projectId) - return result.map((it) => it.apikey) +export const listApiAccessForProject = async (projectId: ProjectId): Promise => { + const queryResult = await getApiAccessForProject(projectId) + return apiAccessSchema.array().parse(queryResult) } diff --git a/services/src/auth/api-access.ts b/services/src/auth/api-access.ts index 44113988..d6ee6a88 100644 --- a/services/src/auth/api-access.ts +++ b/services/src/auth/api-access.ts @@ -5,14 +5,14 @@ import { z } from 'zod' export type ApiKeyCreationParams = Insertable> export type SelectableApiKey = Selectable -const apiKeyIdSchema = z.number().brand('api-access') -export type ApiKeyId = z.infer +const apiAccessIdSchema = z.number().brand('api-access') +export type ApiAccessId = z.infer const apiKeyKeySchema = z.string().uuid().brand('api-key') export type ApiKey = z.infer -const apiAccessSchema = z.object({ - id: apiKeyIdSchema, +export const apiAccessSchema = z.object({ + id: apiAccessIdSchema, apikey: apiKeyKeySchema, name: z.string(), project_id: z.number(), From 68c77cfcb5b9f0e9b87db50579034b48aea45fcd Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Sun, 2 Jun 2024 14:28:18 +0200 Subject: [PATCH 13/15] update server endpoints to conform to new names and types --- services/src/auth/api-access.ts | 6 +++--- .../api/[project]/[lang]/translations/+server.ts | 3 ++- src/routes/(api)/api/[project]/config/+server.ts | 3 ++- src/routes/(api)/api/api-utils.ts | 14 +++++++++----- src/routes/(api)/api/api.model.ts | 3 --- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/services/src/auth/api-access.ts b/services/src/auth/api-access.ts index d6ee6a88..786206d1 100644 --- a/services/src/auth/api-access.ts +++ b/services/src/auth/api-access.ts @@ -8,12 +8,12 @@ export type SelectableApiKey = Selectable const apiAccessIdSchema = z.number().brand('api-access') export type ApiAccessId = z.infer -const apiKeyKeySchema = z.string().uuid().brand('api-key') -export type ApiKey = z.infer +export const apiKeySchema = z.string().uuid().brand('api-key') +export type ApiKey = z.infer export const apiAccessSchema = z.object({ id: apiAccessIdSchema, - apikey: apiKeyKeySchema, + apikey: apiKeySchema, name: z.string(), project_id: z.number(), created_at: z.date(), diff --git a/src/routes/(api)/api/[project]/[lang]/translations/+server.ts b/src/routes/(api)/api/[project]/[lang]/translations/+server.ts index 8474c2a6..c77c1deb 100644 --- a/src/routes/(api)/api/[project]/[lang]/translations/+server.ts +++ b/src/routes/(api)/api/[project]/[lang]/translations/+server.ts @@ -1,6 +1,7 @@ import { validateRequestBody } from '$lib/server/request-utils' +import type { ProjectId } from 'services/project/project' import { authorize } from '../../../api-utils' -import { translationPOSTRequestSchema, type ProjectId } from '../../../api.model' +import { translationPOSTRequestSchema } from '../../../api.model' import type { RequestHandler } from './$types' export const POST: RequestHandler = async ({ params, request }) => { diff --git a/src/routes/(api)/api/[project]/config/+server.ts b/src/routes/(api)/api/[project]/config/+server.ts index 9b9eeb20..e512fd99 100644 --- a/src/routes/(api)/api/[project]/config/+server.ts +++ b/src/routes/(api)/api/[project]/config/+server.ts @@ -1,6 +1,7 @@ import { validateRequestBody } from '$lib/server/request-utils' +import type { ProjectId } from 'services/project/project' import { authorize } from '../../api-utils' -import { projectConfigPOSTRequestSchema, type ProjectId } from '../../api.model' +import { projectConfigPOSTRequestSchema } from '../../api.model' import type { RequestHandler } from './$types' export const POST: RequestHandler = async ({ params, request }) => { diff --git a/src/routes/(api)/api/api-utils.ts b/src/routes/(api)/api/api-utils.ts index 44d4d31d..780568a3 100644 --- a/src/routes/(api)/api/api-utils.ts +++ b/src/routes/(api)/api/api-utils.ts @@ -1,13 +1,17 @@ import { checkApiKeyAccess } from 'services/auth/api-access.service' -import type { ProjectId } from './api.model' import { error } from '@sveltejs/kit' +import { apiKeySchema } from 'services/auth/api-access' +import type { ProjectId } from 'services/project/project' -export const authorize = async (req: Request, project: ProjectId) => { - const apiKey = req.headers.get('Authorization') - if (!apiKey) { +export const authorize = async (req: Request, projectId: ProjectId) => { + if (req.headers.get('Authorization')) { error(401, 'No API key provided in the Authorization header') } - const hasAccess = await checkApiKeyAccess(apiKey, project) + const parsedApiKey = apiKeySchema.safeParse(req.headers.get('Authorization')) + if (parsedApiKey.error) { + error(400, 'The provided API key does not conform to the correct schema') + } + const hasAccess = await checkApiKeyAccess(parsedApiKey.data, projectId) if (!hasAccess) { error( 403, diff --git a/src/routes/(api)/api/api.model.ts b/src/routes/(api)/api/api.model.ts index 981ead11..7bece5b7 100644 --- a/src/routes/(api)/api/api.model.ts +++ b/src/routes/(api)/api/api.model.ts @@ -6,9 +6,6 @@ export type TranslationKey = z.infer const translationValueSchema = z.string().brand('translation-value') export type TranslationValue = z.infer -const projectIdSchema = z.number().brand('project-id') -export type ProjectId = z.infer - const addTranslationCommandSchema = z.object({ key: translationKeySchema, value: translationValueSchema From 6a45b803d42afc3bd4bd1c91aaa9845745741685 Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Sun, 2 Jun 2024 14:44:15 +0200 Subject: [PATCH 14/15] add uuid dependency to root workspace --- package.json | 2 ++ pnpm-lock.yaml | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/package.json b/package.json index 0b7b6ebc..08ef9f48 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "tailwind-merge": "^2.3.0", "tailwind-variants": "^0.2.1", "typesafe-utils": "^1.16.2", + "uuid": "^9.0.1", "zod": "^3.23.5" }, "devDependencies": { @@ -58,6 +59,7 @@ "@types/jsonwebtoken": "^9.0.6", "@types/minimist": "^1.2.5", "@types/node": "^20.12.7", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.6.0", "@typescript-eslint/parser": "^7.6.0", "autoprefixer": "^10.4.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e570e28a..025be49a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: typesafe-utils: specifier: ^1.16.2 version: 1.16.2 + uuid: + specifier: ^9.0.1 + version: 9.0.1 zod: specifier: ^3.23.5 version: 3.23.8 @@ -99,6 +102,9 @@ importers: '@types/node': specifier: ^20.12.7 version: 20.13.0 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 '@typescript-eslint/eslint-plugin': specifier: ^7.6.0 version: 7.11.0(@typescript-eslint/parser@7.11.0(eslint@9.4.0)(typescript@5.4.5))(eslint@9.4.0)(typescript@5.4.5) @@ -765,6 +771,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/uuid@9.0.8': + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/validator@13.11.10': resolution: {integrity: sha512-e2PNXoXLr6Z+dbfx5zSh9TRlXJrELycxiaXznp4S5+D2M3b9bqJEitNHA5923jhnB2zzFiZHa2f0SI1HoIahpg==} @@ -3242,6 +3251,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + valibot@0.30.0: resolution: {integrity: sha512-5POBdbSkM+3nvJ6ZlyQHsggisfRtyT4tVTo1EIIShs6qCdXJnyWU5TJ68vr8iTg5zpOLjXLRiBqNx+9zwZz/rA==} @@ -3922,6 +3935,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/uuid@9.0.8': {} + '@types/validator@13.11.10': optional: true @@ -6579,6 +6594,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@9.0.1: {} + valibot@0.30.0: optional: true From c5f7ec8d051135939baed9dbd27c749fb15f8d85 Mon Sep 17 00:00:00 2001 From: Jacob Palecek Date: Sun, 2 Jun 2024 14:56:10 +0200 Subject: [PATCH 15/15] fix linting issues --- .../api-access-repository.integration.test.ts | 242 +++++++++--------- services/src/auth/api-access.repository.ts | 1 + services/src/auth/api-access.service.ts | 24 +- services/src/auth/api-access.ts | 12 +- .../project-repository.integration.test.ts | 58 ++--- services/src/project/project.ts | 12 +- src/lib/server/request-utils.ts | 1 + .../[project]/[lang]/translations/+server.ts | 8 +- .../(api)/api/[project]/config/+server.ts | 2 +- src/routes/(api)/api/api-utils.ts | 2 + 10 files changed, 185 insertions(+), 177 deletions(-) diff --git a/services/src/auth/api-access-repository.integration.test.ts b/services/src/auth/api-access-repository.integration.test.ts index e5ff99b8..e6e99f47 100644 --- a/services/src/auth/api-access-repository.integration.test.ts +++ b/services/src/auth/api-access-repository.integration.test.ts @@ -3,132 +3,132 @@ import { db } from '../db/database' import { runMigration } from '../db/database-migration-util' import { createProject } from '../project/project.repository' import { - createApiAccess, - deleteApiAccess, - getApiAccessForProject, - projectHasKey, - setApiAccessName + createApiAccess, + deleteApiAccess, + getApiAccessForProject, + projectHasKey, + setApiAccessName } from './api-access.repository' import type { ProjectId } from '../project/project' beforeEach(async () => { - db.reset() - await runMigration() + db.reset() + await runMigration() }) describe('ApiKey Repository', () => { - let projectId: ProjectId - beforeEach(async () => { - projectId = (await createProject({ name: 'demo project name' })).id as ProjectId - }) - - describe('createApiAccess', () => { - it('should create an api key given an existing project', async () => { - const result = await createApiAccess(projectId) - expect(result).toBeTruthy() - expect(false).toBeFalsy() - }) - }) - - describe('getApiAccessForProject', () => { - it('should fetch all apiKeys for a given project', async () => { - const access1 = await createApiAccess(projectId) - const access2 = await createApiAccess(projectId) - - const keys = (await getApiAccessForProject(projectId)).map((it) => it.apikey) - expect(keys).toHaveLength(2) - expect(keys).includes(access1.apikey) - expect(keys).includes(access2.apikey) - }) - - it('should retrieve the name of the apiKey when it is created with one', async () => { - const name = 'some key name' - const key = await createApiAccess(projectId, name) - const result = await getApiAccessForProject(projectId) - - expect(result.find((it) => it.apikey === key.apikey)?.name).toBe(name) - }) - - it('should no longer find deleted keys', async () => { - const key = await createApiAccess(projectId) - - const keysBeforeDelete = await getApiAccessForProject(projectId) - expect(keysBeforeDelete.map((it) => it.apikey)).toContain(key.apikey) - - await deleteApiAccess(projectId, key.apikey) - const keysAfterDelete = await getApiAccessForProject(projectId) - expect(keysAfterDelete.map((it) => it.apikey).includes(key.apikey)).toBe(false) - }) - - it('should return an empty list in case the project does not exist', async () => { - const keys = await getApiAccessForProject(projectId) - expect(keys).toHaveLength(0) - }) - - it('should return an empty list if there are no keys', async () => { - const result = await getApiAccessForProject(projectId) - expect(result).toHaveLength(0) - }) - }) - - describe('setApiAccessName', () => { - it('should be able to set a name when previously there was none', async () => { - const key = await createApiAccess(projectId) - const initialRetrieval = await getApiAccessForProject(projectId).then((it) => - it.find((apiKey) => apiKey.apikey === key.apikey) - ) - - expect(initialRetrieval?.name).toBeFalsy() - const updatedName = 'some new apiKeyName' - await setApiAccessName(key.id, updatedName) - - const secondRetrieval = await getApiAccessForProject(projectId).then((it) => - it.find((apiKey) => apiKey.apikey === key.apikey) - ) - expect(secondRetrieval?.name).toBe(updatedName) - }) - - it('should be able to set the name', async () => { - const initialName = 'my personal api key' - const key = await createApiAccess(projectId, initialName) - const initialRetrieval = await getApiAccessForProject(projectId).then((it) => - it.find((apiKey) => apiKey.apikey === key.apikey) - ) - - expect(initialRetrieval?.name).toBe(initialName) - const updatedName = 'some new apiKeyName' - await setApiAccessName(key.id, updatedName) - - const secondRetrieval = await getApiAccessForProject(projectId).then((it) => - it.find((apiKey) => apiKey.apikey === key.apikey) - ) - expect(secondRetrieval?.name).toBe(updatedName) - }) - }) - - describe('projectHasKey', () => { - it('should return true if there is a key for the project', async () => { - const key = await createApiAccess(projectId) - const result = await projectHasKey(projectId, key.apikey) - expect(result).toBe(true) - }) - - it('should return false if the project does not have the corresponding key', async () => { - const result = await projectHasKey(projectId, 'nonexiststant-id') - expect(result).toBe(false) - }) - - it('should return false if the project does not exist', async () => { - const result = await projectHasKey(4242, 'nonexiststant-id') - expect(result).toBe(false) - }) - - it('should return false if key and project exist, but do not match', async () => { - const otherProjectId = (await createProject({ name: 'another Project' })).id as ProjectId - const key = await createApiAccess(projectId) - - const result = await projectHasKey(otherProjectId, key.apikey) - expect(result).toBe(false) - }) - }) + let projectId: ProjectId + beforeEach(async () => { + projectId = (await createProject({ name: 'demo project name' })).id as ProjectId + }) + + describe('createApiAccess', () => { + it('should create an api key given an existing project', async () => { + const result = await createApiAccess(projectId) + expect(result).toBeTruthy() + expect(false).toBeFalsy() + }) + }) + + describe('getApiAccessForProject', () => { + it('should fetch all apiKeys for a given project', async () => { + const access1 = await createApiAccess(projectId) + const access2 = await createApiAccess(projectId) + + const keys = (await getApiAccessForProject(projectId)).map((it) => it.apikey) + expect(keys).toHaveLength(2) + expect(keys).includes(access1.apikey) + expect(keys).includes(access2.apikey) + }) + + it('should retrieve the name of the apiKey when it is created with one', async () => { + const name = 'some key name' + const key = await createApiAccess(projectId, name) + const result = await getApiAccessForProject(projectId) + + expect(result.find((it) => it.apikey === key.apikey)?.name).toBe(name) + }) + + it('should no longer find deleted keys', async () => { + const key = await createApiAccess(projectId) + + const keysBeforeDelete = await getApiAccessForProject(projectId) + expect(keysBeforeDelete.map((it) => it.apikey)).toContain(key.apikey) + + await deleteApiAccess(projectId, key.apikey) + const keysAfterDelete = await getApiAccessForProject(projectId) + expect(keysAfterDelete.map((it) => it.apikey).includes(key.apikey)).toBe(false) + }) + + it('should return an empty list in case the project does not exist', async () => { + const keys = await getApiAccessForProject(projectId) + expect(keys).toHaveLength(0) + }) + + it('should return an empty list if there are no keys', async () => { + const result = await getApiAccessForProject(projectId) + expect(result).toHaveLength(0) + }) + }) + + describe('setApiAccessName', () => { + it('should be able to set a name when previously there was none', async () => { + const key = await createApiAccess(projectId) + const initialRetrieval = await getApiAccessForProject(projectId).then((it) => + it.find((apiKey) => apiKey.apikey === key.apikey) + ) + + expect(initialRetrieval?.name).toBeFalsy() + const updatedName = 'some new apiKeyName' + await setApiAccessName(key.id, updatedName) + + const secondRetrieval = await getApiAccessForProject(projectId).then((it) => + it.find((apiKey) => apiKey.apikey === key.apikey) + ) + expect(secondRetrieval?.name).toBe(updatedName) + }) + + it('should be able to set the name', async () => { + const initialName = 'my personal api key' + const key = await createApiAccess(projectId, initialName) + const initialRetrieval = await getApiAccessForProject(projectId).then((it) => + it.find((apiKey) => apiKey.apikey === key.apikey) + ) + + expect(initialRetrieval?.name).toBe(initialName) + const updatedName = 'some new apiKeyName' + await setApiAccessName(key.id, updatedName) + + const secondRetrieval = await getApiAccessForProject(projectId).then((it) => + it.find((apiKey) => apiKey.apikey === key.apikey) + ) + expect(secondRetrieval?.name).toBe(updatedName) + }) + }) + + describe('projectHasKey', () => { + it('should return true if there is a key for the project', async () => { + const key = await createApiAccess(projectId) + const result = await projectHasKey(projectId, key.apikey) + expect(result).toBe(true) + }) + + it('should return false if the project does not have the corresponding key', async () => { + const result = await projectHasKey(projectId, 'nonexiststant-id') + expect(result).toBe(false) + }) + + it('should return false if the project does not exist', async () => { + const result = await projectHasKey(4242, 'nonexiststant-id') + expect(result).toBe(false) + }) + + it('should return false if key and project exist, but do not match', async () => { + const otherProjectId = (await createProject({ name: 'another Project' })).id as ProjectId + const key = await createApiAccess(projectId) + + const result = await projectHasKey(otherProjectId, key.apikey) + expect(result).toBe(false) + }) + }) }) diff --git a/services/src/auth/api-access.repository.ts b/services/src/auth/api-access.repository.ts index 595ec6cc..751ca301 100644 --- a/services/src/auth/api-access.repository.ts +++ b/services/src/auth/api-access.repository.ts @@ -11,6 +11,7 @@ export function createApiAccess( name, project_id: projectId } + return db .insertInto('apiaccess') .values(insertKey) diff --git a/services/src/auth/api-access.service.ts b/services/src/auth/api-access.service.ts index b3217cb3..0c416bd9 100644 --- a/services/src/auth/api-access.service.ts +++ b/services/src/auth/api-access.service.ts @@ -1,25 +1,27 @@ import type { ProjectId } from '../project/project' -import { apiAccessSchema, type ApiAccess, type ApiAccessId, type ApiKey } from './api-access' +import { type ApiAccess, type ApiAccessId, type ApiKey, apiAccessSchema } from './api-access' import { - createApiAccess, - getApiAccessForProject, - projectHasKey, - setApiAccessName + createApiAccess, + getApiAccessForProject, + projectHasKey, + setApiAccessName } from './api-access.repository' export const checkApiKeyAccess = async (apiKey: ApiKey, projectId: ProjectId): Promise => - projectHasKey(projectId, apiKey) + projectHasKey(projectId, apiKey) export const addApiAccess = async (projectId: ProjectId): Promise => { - const key = await createApiAccess(projectId) - return apiAccessSchema.parse(key) + const key = await createApiAccess(projectId) + + return apiAccessSchema.parse(key) } export const changeApiAccessName = async (apiAccessId: ApiAccessId, name: string) => { - await setApiAccessName(apiAccessId, name) + await setApiAccessName(apiAccessId, name) } export const listApiAccessForProject = async (projectId: ProjectId): Promise => { - const queryResult = await getApiAccessForProject(projectId) - return apiAccessSchema.array().parse(queryResult) + const queryResult = await getApiAccessForProject(projectId) + + return apiAccessSchema.array().parse(queryResult) } diff --git a/services/src/auth/api-access.ts b/services/src/auth/api-access.ts index 786206d1..7583464b 100644 --- a/services/src/auth/api-access.ts +++ b/services/src/auth/api-access.ts @@ -12,11 +12,11 @@ export const apiKeySchema = z.string().uuid().brand('api-key') export type ApiKey = z.infer export const apiAccessSchema = z.object({ - id: apiAccessIdSchema, - apikey: apiKeySchema, - name: z.string(), - project_id: z.number(), - created_at: z.date(), - updated_at: z.date() + id: apiAccessIdSchema, + apikey: apiKeySchema, + name: z.string(), + project_id: z.number(), + created_at: z.date(), + updated_at: z.date() }) export type ApiAccess = z.infer diff --git a/services/src/project/project-repository.integration.test.ts b/services/src/project/project-repository.integration.test.ts index bbb2690c..8379aff1 100644 --- a/services/src/project/project-repository.integration.test.ts +++ b/services/src/project/project-repository.integration.test.ts @@ -5,48 +5,48 @@ import { runMigration } from '../db/database-migration-util' import { createProject, deleteProjectById, getProjectById } from './project.repository' const projectCreationObject: PojectCreationParams = { - name: 'some project name' + name: 'some project name' } beforeEach(async () => { - db.reset() - await runMigration() + db.reset() + await runMigration() }) describe('Project Repository', () => { - describe('createProject', () => { - it('should create a project with the correct attributes', async () => { - await createProject(projectCreationObject) + describe('createProject', () => { + it('should create a project with the correct attributes', async () => { + await createProject(projectCreationObject) - const projects = await db.selectFrom('projects').selectAll().execute() - expect(projects).toHaveLength(1) + const projects = await db.selectFrom('projects').selectAll().execute() + expect(projects).toHaveLength(1) - const project = projects[0] as SelectableProject + const project = projects[0] as SelectableProject - expect(project).toMatchObject(projectCreationObject) - expect(project.id).toBeTypeOf('number') - }) - }) + expect(project).toMatchObject(projectCreationObject) + expect(project.id).toBeTypeOf('number') + }) + }) - describe('getProjectById', () => { - it('should get a created project with its ID', async () => { - const createdProject = await createProject(projectCreationObject) + describe('getProjectById', () => { + it('should get a created project with its ID', async () => { + const createdProject = await createProject(projectCreationObject) - const retrievedProject = await getProjectById(createdProject.id) + const retrievedProject = await getProjectById(createdProject.id) - expect(retrievedProject.id).toBe(createdProject.id) - }) - }) + expect(retrievedProject.id).toBe(createdProject.id) + }) + }) - describe('deleteProjectById', () => { - it('should delete a project based on its ID', async () => { - const createdProject = await createProject(projectCreationObject) + describe('deleteProjectById', () => { + it('should delete a project based on its ID', async () => { + const createdProject = await createProject(projectCreationObject) - const retrievedProject = await getProjectById(createdProject.id) - expect(retrievedProject).toBeTruthy() + const retrievedProject = await getProjectById(createdProject.id) + expect(retrievedProject).toBeTruthy() - await deleteProjectById(createdProject.id) - expect(() => getProjectById(createdProject.id)).rejects.toThrowError() - }) - }) + await deleteProjectById(createdProject.id) + await expect(() => getProjectById(createdProject.id)).rejects.toThrowError() + }) + }) }) diff --git a/services/src/project/project.ts b/services/src/project/project.ts index 50c2d019..b48efff3 100644 --- a/services/src/project/project.ts +++ b/services/src/project/project.ts @@ -3,7 +3,7 @@ import type { Projects } from 'kysely-codegen' import { z } from 'zod' export type PojectCreationParams = Insertable< - Omit + Omit > export type SelectableProject = Selectable @@ -11,10 +11,10 @@ const projectIdSchema = z.number().brand('projectId') export type ProjectId = z.infer const projectSchema = z.object({ - id: projectIdSchema, - created_at: z.string(), - updated_at: z.string(), - name: z.string(), - base_language: z.number().nullable() + id: projectIdSchema, + created_at: z.string(), + updated_at: z.string(), + name: z.string(), + base_language: z.number().nullable() }) export type Project = z.infer diff --git a/src/lib/server/request-utils.ts b/src/lib/server/request-utils.ts index 04041169..dfa8cc64 100644 --- a/src/lib/server/request-utils.ts +++ b/src/lib/server/request-utils.ts @@ -7,5 +7,6 @@ export const validateRequestBody = async (req: Request, schema: z.ZodSchema { - authorize(request, +params.project as ProjectId) + await authorize(request, +params.project as ProjectId) const newTranslations = validateRequestBody(request, translationPOSTRequestSchema) + return new Response( `getting POST for translations on project "${params.project}" and lang "${params.lang}" with body "${JSON.stringify(newTranslations)}"` ) } -export const GET: RequestHandler = ({ params, request }) => { - authorize(request, +params.project as ProjectId) +export const GET: RequestHandler = async ({ params, request }) => { + await authorize(request, +params.project as ProjectId) + return new Response( `getting GET for translations on project "${params.project}" and lang "${params.lang}"` ) diff --git a/src/routes/(api)/api/[project]/config/+server.ts b/src/routes/(api)/api/[project]/config/+server.ts index e512fd99..15a06166 100644 --- a/src/routes/(api)/api/[project]/config/+server.ts +++ b/src/routes/(api)/api/[project]/config/+server.ts @@ -5,7 +5,7 @@ import { projectConfigPOSTRequestSchema } from '../../api.model' import type { RequestHandler } from './$types' export const POST: RequestHandler = async ({ params, request }) => { - authorize(request, +params.project as ProjectId) + await authorize(request, +params.project as ProjectId) const config = await validateRequestBody(request, projectConfigPOSTRequestSchema) return new Response( diff --git a/src/routes/(api)/api/api-utils.ts b/src/routes/(api)/api/api-utils.ts index 780568a3..9638ca7c 100644 --- a/src/routes/(api)/api/api-utils.ts +++ b/src/routes/(api)/api/api-utils.ts @@ -7,10 +7,12 @@ export const authorize = async (req: Request, projectId: ProjectId) => { if (req.headers.get('Authorization')) { error(401, 'No API key provided in the Authorization header') } + const parsedApiKey = apiKeySchema.safeParse(req.headers.get('Authorization')) if (parsedApiKey.error) { error(400, 'The provided API key does not conform to the correct schema') } + const hasAccess = await checkApiKeyAccess(parsedApiKey.data, projectId) if (!hasAccess) { error(