feat: implement automation key management with middleware and routes for organization API keys, invite improvements

This commit is contained in:
Rotimi Best
2026-03-13 18:40:07 +01:00
parent 77a1188573
commit 492f8dc5ed
71 changed files with 3655 additions and 456 deletions
+12 -37
View File
@@ -1,37 +1,22 @@
/**
* Server-side file type validation utilities for preventing malicious uploads
* Specifically designed to prevent SVG XSS attacks and other security issues
*/
import { ALLOWED_IMAGE_TYPES } from '../validation/constants';
export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const;
/**
* Validate file type by MIME type
* @param file - File object to validate
* @returns boolean indicating if file type is allowed
*/
export function validateImageType(file: File): boolean {
type FileLike = {
type: string;
name: string;
};
export function validateImageType(file: FileLike): boolean {
return ALLOWED_IMAGE_TYPES.includes(file.type as (typeof ALLOWED_IMAGE_TYPES)[number]);
}
/**
* Validate file extension
* @param filename - Filename to validate
* @returns boolean indicating if extension is allowed
*/
export function validateImageExtension(filename: string): boolean {
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return ALLOWED_EXTENSIONS.includes(extension as (typeof ALLOWED_EXTENSIONS)[number]);
}
/**
* Comprehensive file validation
* @param file - File object to validate
* @returns object with validation result and error message
*/
export function validateImageUpload(file: File): { isValid: boolean; error?: string } {
// Check file type
export function validateImageUpload(file: FileLike): { isValid: boolean; error?: string } {
if (!validateImageType(file)) {
return {
isValid: false,
@@ -39,7 +24,6 @@ export function validateImageUpload(file: File): { isValid: boolean; error?: str
};
}
// Check file extension (double check for security)
if (!validateImageExtension(file.name)) {
return {
isValid: false,
@@ -47,7 +31,6 @@ export function validateImageUpload(file: File): { isValid: boolean; error?: str
};
}
// Additional security checks
if (file.name.toLowerCase().includes('.svg')) {
return {
isValid: false,
@@ -58,23 +41,15 @@ export function validateImageUpload(file: File): { isValid: boolean; error?: str
return { isValid: true };
}
/**
* Sanitize filename to prevent directory traversal and other issues
* @param filename - Original filename
* @returns sanitized filename
*/
export function sanitizeFilename(filename: string): string {
// Remove path traversal attempts
let sanitized = filename.replace(/[/\\]/g, '');
// Remove special characters that could cause issues
let sanitized = filename.replace(/[\/\\]/g, '');
sanitized = sanitized.replace(/[<>:"|?*]/g, '');
// Limit length
if (sanitized.length > 255) {
const ext = sanitized.substring(sanitized.lastIndexOf('.'));
const name = sanitized.substring(0, sanitized.lastIndexOf('.'));
sanitized = name.substring(0, 255 - ext.length) + ext;
const extIndex = sanitized.lastIndexOf('.');
const ext = extIndex !== -1 ? sanitized.substring(extIndex) : '';
const name = extIndex !== -1 ? sanitized.substring(0, extIndex) : sanitized;
sanitized = `${name.substring(0, 255 - ext.length)}${ext}`;
}
return sanitized;
+2 -1
View File
@@ -1,2 +1,3 @@
export * from './fileValidation';
export * from './array';
export * from './fileValidation';
export * from './slug';
+19
View File
@@ -0,0 +1,19 @@
export type GenerateSlugOptions = {
fallback?: string;
appendTimestamp?: boolean;
};
export function generateSlug(source: string | null | undefined, options: GenerateSlugOptions = {}): string {
const { fallback = 'item', appendTimestamp = false } = options;
const base =
(source ?? fallback)
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || fallback;
return appendTimestamp ? `${base}-${Date.now()}` : base;
}
@@ -0,0 +1,138 @@
import * as z from 'zod';
import { ZCourseMetadata } from '../course/course';
const ZSupportedLocale = z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
export const ZCourseImportWarning = z.object({
code: z.string().min(1),
message: z.string().min(1),
severity: z.enum(['info', 'warning', 'error'])
});
export type TCourseImportWarning = z.infer<typeof ZCourseImportWarning>;
export const ZCourseImportSourceReference = z.object({
type: z.enum(['prompt', 'pdf']),
label: z.string().min(1),
pageStart: z.number().int().min(1).optional(),
pageEnd: z.number().int().min(1).optional()
});
export type TCourseImportSourceReference = z.infer<typeof ZCourseImportSourceReference>;
export const ZCourseImportDraftCourse = z.object({
title: z.string().min(1),
description: z.string().min(1),
type: z.enum(['LIVE_CLASS', 'SELF_PACED']),
locale: ZSupportedLocale.default('en'),
metadata: ZCourseMetadata.optional()
});
export type TCourseImportDraftCourse = z.infer<typeof ZCourseImportDraftCourse>;
export const ZCourseImportDraftSection = z.object({
externalId: z.string().min(1),
title: z.string().min(1),
order: z.number().int().min(0)
});
export type TCourseImportDraftSection = z.infer<typeof ZCourseImportDraftSection>;
export const ZCourseImportDraftLesson = z.object({
externalId: z.string().min(1),
sectionExternalId: z.string().min(1),
title: z.string().min(1),
order: z.number().int().min(0),
isUnlocked: z.boolean().optional(),
public: z.boolean().optional()
});
export type TCourseImportDraftLesson = z.infer<typeof ZCourseImportDraftLesson>;
export const ZCourseImportDraftLessonLanguage = z.object({
lessonExternalId: z.string().min(1),
locale: ZSupportedLocale,
content: z.string().min(1)
});
export type TCourseImportDraftLessonLanguage = z.infer<typeof ZCourseImportDraftLessonLanguage>;
export const ZCourseImportDraftPayload = z
.object({
course: ZCourseImportDraftCourse,
sections: z.array(ZCourseImportDraftSection).min(1),
lessons: z.array(ZCourseImportDraftLesson).min(1),
lessonLanguages: z.array(ZCourseImportDraftLessonLanguage).min(1),
exercises: z.array(z.record(z.string(), z.unknown())).optional(),
sourceReferences: z.array(ZCourseImportSourceReference).optional(),
warnings: z.array(ZCourseImportWarning).default([])
})
.superRefine((value, ctx) => {
const sectionIds = new Set<string>();
value.sections.forEach((section, index) => {
if (sectionIds.has(section.externalId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['sections', index, 'externalId'],
message: 'Section externalId must be unique'
});
}
sectionIds.add(section.externalId);
});
const lessonIds = new Set<string>();
value.lessons.forEach((lesson, index) => {
if (lessonIds.has(lesson.externalId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['lessons', index, 'externalId'],
message: 'Lesson externalId must be unique'
});
}
lessonIds.add(lesson.externalId);
if (!sectionIds.has(lesson.sectionExternalId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['lessons', index, 'sectionExternalId'],
message: 'Lesson sectionExternalId must reference an existing section'
});
}
});
value.lessonLanguages.forEach((lessonLanguage, index) => {
if (!lessonIds.has(lessonLanguage.lessonExternalId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['lessonLanguages', index, 'lessonExternalId'],
message: 'Lesson language must reference an existing lesson'
});
}
});
});
export type TCourseImportDraftPayload = z.infer<typeof ZCourseImportDraftPayload>;
export const ZCourseImportDraftCreate = z.object({
sourceType: z.enum(['prompt', 'pdf']),
idempotencyKey: z.string().min(1).optional(),
summary: z.record(z.string(), z.unknown()).optional(),
sourceArtifacts: z.array(z.record(z.string(), z.unknown())).optional(),
draft: ZCourseImportDraftPayload
});
export type TCourseImportDraftCreate = z.infer<typeof ZCourseImportDraftCreate>;
export const ZCourseImportDraftGetParam = z.object({
draftId: z.string().uuid()
});
export type TCourseImportDraftGetParam = z.infer<typeof ZCourseImportDraftGetParam>;
export const ZCourseImportDraftUpdate = z.object({
summary: z.record(z.string(), z.unknown()).optional(),
sourceArtifacts: z.array(z.record(z.string(), z.unknown())).optional(),
warnings: z.array(ZCourseImportWarning).optional(),
draft: ZCourseImportDraftPayload.optional()
});
export type TCourseImportDraftUpdate = z.infer<typeof ZCourseImportDraftUpdate>;
export const ZCourseImportDraftPublish = z.object({
title: z.string().min(1).optional(),
description: z.string().min(1).optional(),
type: z.enum(['LIVE_CLASS', 'SELF_PACED']).optional(),
metadata: ZCourseMetadata.optional()
});
export type TCourseImportDraftPublish = z.infer<typeof ZCourseImportDraftPublish>;
@@ -0,0 +1 @@
export * from './course-import';
+1
View File
@@ -4,6 +4,7 @@ export * from './account';
export * from './attendance';
export * from './assets';
export * from './course';
export * from './course-import';
export * from './exercise';
export * from './lesson';
export * from './mail';
@@ -0,0 +1,31 @@
import { z } from 'zod';
export const ZOrganizationApiKeyType = z.enum(['mcp', 'api', 'zapier']);
export type TOrganizationApiKeyType = z.infer<typeof ZOrganizationApiKeyType>;
export const ZOrganizationApiKeyScope = z.enum([
'course_import:draft:create',
'course_import:draft:read',
'course_import:draft:update',
'course_import:draft:publish',
'public_api:*'
]);
export type TOrganizationApiKeyScope = z.infer<typeof ZOrganizationApiKeyScope>;
export const ZListOrganizationApiKeysQuery = z.object({
type: ZOrganizationApiKeyType.optional()
});
export type TListOrganizationApiKeysQuery = z.infer<typeof ZListOrganizationApiKeysQuery>;
export const ZCreateOrganizationApiKey = z.object({
type: ZOrganizationApiKeyType,
label: z.string().min(1).max(120).optional(),
expiresAt: z.string().datetime().optional(),
scopes: z.array(ZOrganizationApiKeyScope).min(1).optional()
});
export type TCreateOrganizationApiKey = z.infer<typeof ZCreateOrganizationApiKey>;
export const ZOrganizationApiKeyParam = z.object({
keyId: z.string().uuid()
});
export type TOrganizationApiKeyParam = z.infer<typeof ZOrganizationApiKeyParam>;
@@ -5,3 +5,4 @@ export * from './invite';
export * from './sso';
export * from './login-link';
export * from './token-auth';
export * from './automation-key';