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
@@ -0,0 +1,254 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
createCourseImportDraft,
getCourseImportDraftById,
getCourseImportDraftByIdempotencyKey,
updateCourseImportDraft
} from '@cio/db/queries/course-import';
import type { TCourseImportDraft, TLocale } from '@db/types';
import type {
TCourseImportDraftCreate,
TCourseImportDraftPayload,
TCourseImportDraftPublish,
TCourseImportDraftUpdate
} from '@cio/utils/validation/course-import';
import { ZCourseImportDraftPayload } from '@cio/utils/validation/course-import';
import { createCourse, updateCourse } from '@api/services/course/course';
import { createCourseSection } from '@api/services/course/section';
import { createLesson } from '@api/services/lesson';
import { upsertLessonLanguageService } from '@api/services/lesson-language';
type DraftSummary = {
sectionCount: number;
lessonCount: number;
localeCount: number;
warningCount: number;
};
function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
return {
sectionCount: draft.sections.length,
lessonCount: draft.lessons.length,
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size,
warningCount: draft.warnings.length
};
}
function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload {
return ZCourseImportDraftPayload.parse(record.draft);
}
export async function createCourseImportDraftService(orgId: string, profileId: string, data: TCourseImportDraftCreate) {
try {
if (data.idempotencyKey) {
const existing = await getCourseImportDraftByIdempotencyKey(orgId, data.idempotencyKey);
if (existing) {
return existing;
}
}
return await createCourseImportDraft({
organizationId: orgId,
createdByProfileId: profileId,
sourceType: data.sourceType,
status: 'DRAFT',
title: data.draft.course.title,
locale: data.draft.course.locale,
idempotencyKey: data.idempotencyKey,
summary: {
...buildDraftSummary(data.draft),
...(data.summary ?? {})
},
draft: data.draft,
warnings: data.draft.warnings,
sourceArtifacts: data.sourceArtifacts ?? data.draft.sourceReferences ?? []
});
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to create course import draft',
ErrorCodes.COURSE_IMPORT_CREATE_FAILED,
500
);
}
}
export async function getCourseImportDraftService(orgId: string, draftId: string) {
try {
const draft = await getCourseImportDraftById(orgId, draftId);
if (!draft) {
throw new AppError('Course import draft not found', ErrorCodes.COURSE_IMPORT_DRAFT_NOT_FOUND, 404);
}
return draft;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch course import draft',
ErrorCodes.COURSE_IMPORT_FETCH_FAILED,
500
);
}
}
export async function updateCourseImportDraftService(orgId: string, draftId: string, data: TCourseImportDraftUpdate) {
try {
const existing = await getCourseImportDraftService(orgId, draftId);
if (existing.status === 'PUBLISHED') {
throw new AppError('Published drafts cannot be edited', ErrorCodes.FORBIDDEN, 403);
}
const nextDraft = data.draft ?? parseStoredDraft(existing);
const nextWarnings = data.warnings ?? nextDraft.warnings;
const updated = await updateCourseImportDraft(orgId, draftId, {
title: nextDraft.course.title,
locale: nextDraft.course.locale,
draft: nextDraft,
warnings: nextWarnings,
summary: {
...(existing.summary ?? {}),
...buildDraftSummary(nextDraft),
...(data.summary ?? {})
},
sourceArtifacts: data.sourceArtifacts ?? existing.sourceArtifacts
});
if (!updated) {
throw new AppError('Course import draft not found', ErrorCodes.COURSE_IMPORT_DRAFT_NOT_FOUND, 404);
}
return updated;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update course import draft',
ErrorCodes.COURSE_IMPORT_UPDATE_FAILED,
500
);
}
}
export async function publishCourseImportDraftService(
orgId: string,
profileId: string,
draftId: string,
overrides: TCourseImportDraftPublish
) {
try {
const existing = await getCourseImportDraftService(orgId, draftId);
if (existing.status === 'PUBLISHED' && existing.publishedCourseId) {
return {
courseId: existing.publishedCourseId,
createdSections: 0,
createdLessons: 0,
localeCount: 0
};
}
const draft = parseStoredDraft(existing);
const courseTitle = overrides.title ?? draft.course.title;
const courseDescription = overrides.description ?? draft.course.description;
const courseType = overrides.type ?? draft.course.type;
const courseMetadata = overrides.metadata ?? draft.course.metadata;
const sectionExternalIds = new Set(draft.sections.map((section) => section.externalId));
draft.lessons.forEach((lesson) => {
if (!sectionExternalIds.has(lesson.sectionExternalId)) {
throw new AppError(`Lesson "${lesson.title}" references an unknown section`, ErrorCodes.VALIDATION_ERROR, 400);
}
});
const courseResult = await createCourse(profileId, {
title: courseTitle,
description: courseDescription,
type: courseType,
organizationId: orgId
});
const courseId = courseResult.course.id;
if (courseMetadata) {
await updateCourse(courseId, { metadata: courseMetadata });
}
const sectionIdMap = new Map<string, string>();
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
const createdSection = await createCourseSection(courseId, {
courseId,
title: section.title,
order: section.order
});
sectionIdMap.set(section.externalId, createdSection.id);
}
const lessonIdMap = new Map<string, string>();
for (const lesson of [...draft.lessons].sort((a, b) => a.order - b.order)) {
const sectionId = sectionIdMap.get(lesson.sectionExternalId);
if (!sectionId) {
throw new AppError('Referenced section was not created', ErrorCodes.VALIDATION_ERROR, 400);
}
const createdLesson = await createLesson(courseId, {
courseId,
title: lesson.title,
sectionId,
order: lesson.order,
isUnlocked: lesson.isUnlocked,
public: lesson.public
});
lessonIdMap.set(lesson.externalId, createdLesson.id);
}
for (const lessonLanguage of draft.lessonLanguages) {
const lessonId = lessonIdMap.get(lessonLanguage.lessonExternalId);
if (!lessonId) {
throw new AppError('Referenced lesson was not created', ErrorCodes.VALIDATION_ERROR, 400);
}
await upsertLessonLanguageService(lessonId, {
locale: lessonLanguage.locale as TLocale,
content: lessonLanguage.content
});
}
await updateCourseImportDraft(orgId, draftId, {
status: 'PUBLISHED',
publishedCourseId: courseId,
summary: {
...(existing.summary ?? {}),
...buildDraftSummary(draft),
publishedCourseId: courseId
}
});
return {
courseId,
createdSections: sectionIdMap.size,
createdLessons: lessonIdMap.size,
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to publish course import draft',
ErrorCodes.COURSE_IMPORT_PUBLISH_FAILED,
500
);
}
}
+52 -4
View File
@@ -23,6 +23,8 @@ import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { getProfileByEmail } from '@cio/db/queries/auth';
import { generateSlug } from '@cio/utils/functions';
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
@@ -38,6 +40,39 @@ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ANOMALY_WINDOW_MINUTES = 60;
const MAX_PREVIEW_IP_DIVERSITY = 25;
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
let suffix = 0;
let candidate = baseSlug;
while (true) {
const existing = await db
.select({ id: schema.course.id })
.from(schema.course)
.where(eq(schema.course.slug, candidate))
.limit(1);
if (existing.length === 0) {
return candidate;
}
suffix += 1;
candidate = `${baseSlug}-${suffix}`;
}
}
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
const baseSlug = generateSlug(title, { fallback: 'course' });
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
const [updated] = await db
.update(schema.course)
.set({ slug: uniqueSlug, updatedAt: new Date().toISOString() })
.where(eq(schema.course.id, courseId))
.returning({ slug: schema.course.slug });
return updated?.slug ?? uniqueSlug;
}
interface TInviteRequestContext {
ipAddress?: string | null;
userAgent?: string | null;
@@ -481,11 +516,9 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
);
}
const courseSlug = course[0].slug;
const courseTitle = course[0].title;
const courseSlug = course[0].slug ?? (await ensureCourseSlug(course[0].id, courseTitle));
const orgSiteName = courseOrgData.orgSiteName ?? null;
if (!courseSlug) {
throw new AppError('Course must have a slug to create invite links', ErrorCodes.VALIDATION_ERROR, 400);
}
if (recipients.valid.length === 0) {
const createdInvite = await createSingleInvite(
@@ -803,6 +836,17 @@ export async function previewStudentInvite(token: string, context: TInviteReques
});
const status = getInviteStatus(data.invite);
const recipientEmail = data.invite.allowedEmails?.[0]?.toLowerCase().trim() || null;
let recipientExists = false;
if (recipientEmail) {
try {
const profile = await getProfileByEmail(recipientEmail);
recipientExists = !!profile;
} catch (error) {
console.error('previewStudentInvite getProfileByEmail error:', error);
}
}
const courseMetadata = data.course.metadata as { allowNewStudent?: boolean } | null;
return {
@@ -828,6 +872,10 @@ export async function previewStudentInvite(token: string, context: TInviteReques
status: data.course.status,
slug: data.course.slug ?? undefined,
cost: data.course.cost
},
inviteContext: {
recipientEmail,
recipientExists
}
};
}
@@ -0,0 +1,166 @@
import type {
TCreateOrganizationApiKey,
TListOrganizationApiKeysQuery,
TOrganizationApiKeyScope,
TOrganizationApiKeyType
} from '@cio/utils/validation/organization';
import type { TOrganizationApiKey } from '@db/types';
import { createHash, randomBytes } from 'crypto';
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
createOrganizationApiKey,
getActiveOrganizationApiKeyByHash,
getOrganizationApiKeyById,
listOrganizationApiKeys,
updateOrganizationApiKey
} from '@cio/db/queries/organization';
const DEFAULT_SCOPES: Record<TOrganizationApiKeyType, TOrganizationApiKeyScope[]> = {
mcp: [
'course_import:draft:create',
'course_import:draft:read',
'course_import:draft:update',
'course_import:draft:publish'
],
api: ['public_api:*'],
zapier: ['public_api:*']
};
function getDefaultLabel(type: TOrganizationApiKeyType) {
switch (type) {
case 'mcp':
return 'MCP key';
case 'api':
return 'API key';
case 'zapier':
return 'Zapier key';
}
}
function generateOrganizationApiKeySecret(type: TOrganizationApiKeyType) {
return `cio_${type}_${randomBytes(24).toString('base64url')}`;
}
function hashSecret(secret: string) {
return createHash('sha256').update(secret).digest('hex');
}
function getSecretPrefix(secret: string) {
return secret.slice(0, 16);
}
function isExpired(expiresAt: string | null) {
return Boolean(expiresAt && new Date(expiresAt).getTime() <= Date.now());
}
export type OrganizationApiKeySummary = Omit<TOrganizationApiKey, 'secretHash'>;
function sanitizeOrganizationApiKey(key: TOrganizationApiKey): OrganizationApiKeySummary {
const { secretHash: _secretHash, ...rest } = key;
return rest;
}
export function organizationApiKeyHasScopes(
keyScopes: string[],
requiredScopes: readonly TOrganizationApiKeyScope[]
): boolean {
if (requiredScopes.length === 0) {
return true;
}
const scopeSet = new Set(keyScopes);
return requiredScopes.every((scope) => scopeSet.has(scope));
}
export async function listOrganizationApiKeysService(
organizationId: string,
query: TListOrganizationApiKeysQuery
): Promise<OrganizationApiKeySummary[]> {
const keys = await listOrganizationApiKeys(organizationId, query.type);
return keys.map(sanitizeOrganizationApiKey);
}
export async function createOrganizationApiKeyService(
organizationId: string,
createdByProfileId: string,
payload: TCreateOrganizationApiKey
): Promise<{ key: OrganizationApiKeySummary; secret: string }> {
const secret = generateOrganizationApiKeySecret(payload.type);
const key = await createOrganizationApiKey({
organizationId,
createdByProfileId,
type: payload.type,
label: payload.label?.trim() || getDefaultLabel(payload.type),
secretPrefix: getSecretPrefix(secret),
secretHash: hashSecret(secret),
scopes: payload.scopes ?? DEFAULT_SCOPES[payload.type],
expiresAt: payload.expiresAt ?? null
});
return {
key: sanitizeOrganizationApiKey(key),
secret
};
}
export async function revokeOrganizationApiKeyService(organizationId: string, keyId: string) {
const existing = await getOrganizationApiKeyById(organizationId, keyId);
if (!existing) {
throw new AppError('Organization API key not found', ErrorCodes.ORGANIZATION_API_KEY_NOT_FOUND, 404);
}
const updated = await updateOrganizationApiKey(organizationId, keyId, {
revokedAt: new Date().toISOString()
});
if (!updated) {
throw new AppError('Failed to revoke organization API key', ErrorCodes.ORGANIZATION_API_KEY_REVOKE_FAILED, 500);
}
return sanitizeOrganizationApiKey(updated);
}
export async function rotateOrganizationApiKeyService(organizationId: string, keyId: string) {
const existing = await getOrganizationApiKeyById(organizationId, keyId);
if (!existing) {
throw new AppError('Organization API key not found', ErrorCodes.ORGANIZATION_API_KEY_NOT_FOUND, 404);
}
const secret = generateOrganizationApiKeySecret(existing.type);
const updated = await updateOrganizationApiKey(organizationId, keyId, {
secretPrefix: getSecretPrefix(secret),
secretHash: hashSecret(secret),
revokedAt: null,
lastUsedAt: null
});
if (!updated) {
throw new AppError('Failed to rotate organization API key', ErrorCodes.ORGANIZATION_API_KEY_ROTATE_FAILED, 500);
}
return {
key: sanitizeOrganizationApiKey(updated),
secret
};
}
export async function authenticateOrganizationApiKeyService(rawSecret: string): Promise<TOrganizationApiKey> {
const key = await getActiveOrganizationApiKeyByHash(hashSecret(rawSecret));
if (!key) {
throw new AppError('Invalid organization API key', ErrorCodes.UNAUTHORIZED, 401);
}
if (isExpired(key.expiresAt ?? null)) {
throw new AppError('Organization API key has expired', ErrorCodes.UNAUTHORIZED, 401);
}
return key;
}
export async function touchOrganizationApiKeyLastUsedService(key: TOrganizationApiKey): Promise<void> {
await updateOrganizationApiKey(key.organizationId, key.id, {
lastUsedAt: new Date().toISOString()
});
}