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,29 @@
import { Context, Next } from 'hono';
import { authMiddleware } from './auth';
import { automationKeyMiddleware } from './automation-key';
export const authOrAutomationKeyMiddleware = async (c: Context, next: Next) => {
const user = c.get('user');
const session = c.get('session');
if (user && session) {
c.set('automationKey', null);
await authMiddleware(c, next);
return;
}
const authHeader = c.req.header('Authorization');
if (authHeader?.startsWith('Bearer ')) {
return automationKeyMiddleware(c, next);
}
return c.json(
{
success: false,
message: 'Unauthorized: Authentication required (session or automation key)'
},
401
);
};
@@ -0,0 +1,42 @@
import { Context, Next } from 'hono';
import {
authenticateOrganizationApiKeyService,
touchOrganizationApiKeyLastUsedService
} from '@api/services/organization/automation-key';
export const automationKeyMiddleware = async (c: Context, next: Next) => {
try {
const authHeader = c.req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json(
{
success: false,
error: 'Unauthorized',
code: 'UNAUTHORIZED'
},
401
);
}
const apiKey = authHeader.replace('Bearer ', '').trim();
const automationKey = await authenticateOrganizationApiKeyService(apiKey);
c.set('automationKey', automationKey);
c.set('orgId', automationKey.organizationId);
c.set('actorId', automationKey.createdByProfileId);
await touchOrganizationApiKeyLastUsedService(automationKey);
await next();
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unauthorized',
code: 'UNAUTHORIZED'
},
401
);
}
};
@@ -0,0 +1,75 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import { isUserOrgAdmin } from '@cio/db/queries/organization';
export const orgAdminOrApiKeyMiddleware = async (c: Context, next: Next) => {
try {
const orgId = c.req.header('cio-org-id');
if (!orgId) {
return c.json(
{
success: false,
error: 'Organization ID is required',
code: 'ORG_ID_REQUIRED'
},
400
);
}
const user = c.get('user');
const authHeader = c.req.header('Authorization');
const apiKey = authHeader?.startsWith('Bearer ') ? authHeader.replace('Bearer ', '').trim() : null;
const isApiKeyRequest = Boolean(apiKey && env.PRIVATE_SERVER_KEY && apiKey === env.PRIVATE_SERVER_KEY);
const actorId = user?.id ?? c.req.header('x-cio-actor-id');
if (!actorId) {
return c.json(
{
success: false,
error: 'Actor ID is required',
code: 'ACTOR_ID_REQUIRED'
},
400
);
}
const isAdmin = await isUserOrgAdmin(orgId, actorId);
if (!isAdmin) {
return c.json(
{
success: false,
error: 'Only organization admins can perform this action',
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
},
403
);
}
if (!user && !isApiKeyRequest) {
return c.json(
{
success: false,
error: 'Unauthorized',
code: ErrorCodes.UNAUTHORIZED
},
401
);
}
c.set('actorId', actorId);
await next();
} catch (error) {
console.error('Error in orgAdminOrApiKeyMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify organization admin status',
code: 'ORG_ADMIN_CHECK_FAILED'
},
500
);
}
};
@@ -0,0 +1,85 @@
import { Context, Next } from 'hono';
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
import { ErrorCodes } from '@api/utils/errors';
import { isUserOrgAdmin } from '@cio/db/queries/organization';
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
export const orgAdminOrAutomationKeyMiddleware =
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
async (c: Context, next: Next) => {
try {
const automationKey = c.get('automationKey');
if (automationKey) {
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
return c.json(
{
success: false,
error: 'Automation key is missing required scopes',
code: ErrorCodes.FORBIDDEN
},
403
);
}
c.set('orgId', automationKey.organizationId);
c.set('actorId', automationKey.createdByProfileId);
await next();
return;
}
const user = c.get('user');
if (!user) {
return c.json(
{
success: false,
error: 'Unauthorized',
code: ErrorCodes.UNAUTHORIZED
},
401
);
}
const orgId = c.req.header('cio-org-id');
if (!orgId) {
return c.json(
{
success: false,
error: 'Organization ID is required',
code: 'ORG_ID_REQUIRED'
},
400
);
}
const isAdmin = await isUserOrgAdmin(orgId, user.id);
if (!isAdmin) {
return c.json(
{
success: false,
error: 'Only organization admins can perform this action',
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
},
403
);
}
c.set('orgId', orgId);
c.set('actorId', user.id);
await next();
} catch (error) {
console.error('Error in orgAdminOrAutomationKeyMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify organization admin status',
code: 'ORG_ADMIN_CHECK_FAILED'
},
500
);
}
};
@@ -0,0 +1,76 @@
import {
ZCreateOrganizationApiKey,
ZListOrganizationApiKeysQuery,
ZOrganizationApiKeyParam
} from '@cio/utils/validation/organization';
import {
createOrganizationApiKeyService,
listOrganizationApiKeysService,
revokeOrganizationApiKeyService,
rotateOrganizationApiKeyService
} from '@api/services/organization/automation-key';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { zValidator } from '@hono/zod-validator';
export const automationRouter = new Hono()
.get('/keys', authMiddleware, orgAdminMiddleware, zValidator('query', ZListOrganizationApiKeysQuery), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const query = c.req.valid('query');
const keys = await listOrganizationApiKeysService(orgId, query);
return c.json({ success: true, data: keys }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list organization automation keys');
}
})
.post('/keys', authMiddleware, orgAdminMiddleware, zValidator('json', ZCreateOrganizationApiKey), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const payload = c.req.valid('json');
const key = await createOrganizationApiKeyService(orgId, user.id, payload);
return c.json({ success: true, data: key }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create organization automation key');
}
})
.delete(
'/keys/:keyId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZOrganizationApiKeyParam),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { keyId } = c.req.valid('param');
const key = await revokeOrganizationApiKeyService(orgId, keyId);
return c.json({ success: true, data: key }, 200);
} catch (error) {
return handleError(c, error, 'Failed to revoke organization automation key');
}
}
)
.post(
'/keys/:keyId/rotate',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZOrganizationApiKeyParam),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { keyId } = c.req.valid('param');
const key = await rotateOrganizationApiKeyService(orgId, keyId);
return c.json({ success: true, data: key }, 200);
} catch (error) {
return handleError(c, error, 'Failed to rotate organization automation key');
}
}
);
@@ -0,0 +1,98 @@
import {
ZCourseImportDraftCreate,
ZCourseImportDraftGetParam,
ZCourseImportDraftPublish,
ZCourseImportDraftUpdate
} from '@cio/utils/validation/course-import';
import {
createCourseImportDraftService,
getCourseImportDraftService,
publishCourseImportDraftService,
updateCourseImportDraftService
} from '@api/services/course-import/course-import';
import { Hono } from '@api/utils/hono';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { handleError } from '@api/utils/errors';
import { orgAdminOrAutomationKeyMiddleware } from '@api/middlewares/org-admin-or-automation-key';
import { zValidator } from '@hono/zod-validator';
export const courseImportRouter = new Hono()
.post(
'/drafts',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:create']),
zValidator('json', ZCourseImportDraftCreate),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId')!;
const payload = c.req.valid('json');
const draft = await createCourseImportDraftService(orgId, actorId, payload);
return c.json({ success: true, data: draft }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create course import draft');
}
}
)
.get(
'/drafts/:draftId',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:read']),
zValidator('param', ZCourseImportDraftGetParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const { draftId } = c.req.valid('param');
const draft = await getCourseImportDraftService(orgId, draftId);
return c.json({ success: true, data: draft }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch course import draft');
}
}
)
.put(
'/drafts/:draftId',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:update']),
zValidator('param', ZCourseImportDraftGetParam),
zValidator('json', ZCourseImportDraftUpdate),
async (c) => {
try {
const orgId = c.get('orgId')!;
const { draftId } = c.req.valid('param');
const payload = c.req.valid('json');
const draft = await updateCourseImportDraftService(orgId, draftId, payload);
return c.json({ success: true, data: draft }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update course import draft');
}
}
)
.post(
'/drafts/:draftId/publish',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:publish']),
zValidator('param', ZCourseImportDraftGetParam),
zValidator('json', ZCourseImportDraftPublish),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId')!;
const { draftId } = c.req.valid('param');
const payload = c.req.valid('json');
const result = await publishCourseImportDraftService(orgId, actorId, draftId, payload);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to publish course import draft');
}
}
);
@@ -2,3 +2,5 @@ export * from './organization';
export * from './assets';
export * from './tags';
export * from './token-auth';
export * from './course-import';
export * from './automation';
@@ -34,9 +34,11 @@ import {
import { Hono } from '@api/utils/hono';
import { TOrganization } from '@db/types';
import { automationRouter } from '@api/routes/organization/automation';
import { assetsRouter } from '@api/routes/organization/assets';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { courseImportRouter } from '@api/routes/organization/course-import';
import { getLMSExercisesService } from '@api/services/exercise';
import { handleError } from '@api/utils/errors';
import { inviteTeamMembers } from '@api/services/organization/invite';
@@ -505,6 +507,8 @@ export const organizationRouter = new Hono()
return handleError(c, error, 'Failed to fetch LMS exercises');
}
})
.route('/automation', automationRouter)
.route('/course-import', courseImportRouter)
.route('/tags', tagsRouter)
.route('/assets', assetsRouter)
.route('/:orgId/quiz', quizRouter);
@@ -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()
});
}
+3
View File
@@ -1,7 +1,10 @@
import { auth } from '@cio/db/auth';
import type { TOrganizationApiKey } from '@db/types';
export type AuthSession = {
Variables: {
actorId: string | null;
automationKey: TOrganizationApiKey | null;
orgId: string | null;
session: typeof auth.$Infer.Session.session | null;
user: typeof auth.$Infer.Session.user | null;
+10
View File
@@ -62,6 +62,11 @@ export const ErrorCodes = {
ORG_TEAM_REMOVE_FAILED: 'ORG_TEAM_REMOVE_FAILED',
ORG_TEAM_EMAIL_EXISTS: 'ORG_TEAM_EMAIL_EXISTS',
ORG_TEAM_NOT_AUTHORIZED: 'ORG_TEAM_NOT_AUTHORIZED',
ORGANIZATION_API_KEY_NOT_FOUND: 'ORGANIZATION_API_KEY_NOT_FOUND',
ORGANIZATION_API_KEY_CREATE_FAILED: 'ORGANIZATION_API_KEY_CREATE_FAILED',
ORGANIZATION_API_KEY_FETCH_FAILED: 'ORGANIZATION_API_KEY_FETCH_FAILED',
ORGANIZATION_API_KEY_REVOKE_FAILED: 'ORGANIZATION_API_KEY_REVOKE_FAILED',
ORGANIZATION_API_KEY_ROTATE_FAILED: 'ORGANIZATION_API_KEY_ROTATE_FAILED',
TAG_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND',
TAG_NOT_FOUND: 'TAG_NOT_FOUND',
TAG_FETCH_FAILED: 'TAG_FETCH_FAILED',
@@ -97,6 +102,11 @@ export const ErrorCodes = {
// Course errors
COURSE_NOT_FOUND: 'COURSE_NOT_FOUND',
COURSE_FETCH_FAILED: 'COURSE_FETCH_FAILED',
COURSE_IMPORT_DRAFT_NOT_FOUND: 'COURSE_IMPORT_DRAFT_NOT_FOUND',
COURSE_IMPORT_CREATE_FAILED: 'COURSE_IMPORT_CREATE_FAILED',
COURSE_IMPORT_FETCH_FAILED: 'COURSE_IMPORT_FETCH_FAILED',
COURSE_IMPORT_UPDATE_FAILED: 'COURSE_IMPORT_UPDATE_FAILED',
COURSE_IMPORT_PUBLISH_FAILED: 'COURSE_IMPORT_PUBLISH_FAILED',
// Lesson errors
LESSON_NOT_FOUND: 'LESSON_NOT_FOUND',