feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
+4 -140
View File
@@ -1,6 +1,10 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { Context } from 'hono';
import { ErrorCodes, type ErrorCode } from '@cio/utils/constants';
export { ErrorCodes, type ErrorCode };
/**
* Standard application error with code for easy identification
*/
@@ -26,146 +30,6 @@ export interface ErrorResponse {
field?: string;
}
// Error codes constants for consistency
export const ErrorCodes = {
// Common errors
NOT_FOUND: 'NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
// Dashboard Analytics errors
ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED',
NO_ORG_ID_PROVIDED: 'NO_ORG_ID_PROVIDED',
// Community errors
UPDATE_COMMUNITY_QUESTION_FAILED: 'UPDATE_COMMUNITY_QUESTION_FAILED',
ADD_COMMUNITY_QUESTION_FAILED: 'ADD_COMMUNITY_QUESTION_FAILED',
COMMUNITY_QUESTIONS_FETCH_FAILED: 'COMMUNITY_QUESTIONS_FETCH_FAILED',
COMMUNITY_QUESTION_NOT_FOUND: 'COMMUNITY_QUESTION_NOT_FOUND',
COMMENT_SUBMISSION_FAILED: 'COMMENT_SUBMISSION_FAILED',
UPVOTE_FAILED: 'UPVOTE_FAILED',
DELETE_COMMENT_FAILED: 'DELETE_COMMENT_FAILED',
DELETE_QUESTION_FAILED: 'DELETE_QUESTION_FAILED',
// Organization errors
SITENAME_EXISTS: 'SITENAME_EXISTS',
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
ORG_NOT_FOUND: 'ORG_NOT_FOUND',
ORGANIZATION_NOT_FOUND: 'ORGANIZATION_NOT_FOUND',
ORG_TEAM_FETCH_FAILED: 'ORG_TEAM_FETCH_FAILED',
ORG_AUDIENCE_FETCH_FAILED: 'ORG_AUDIENCE_FETCH_FAILED',
COURSES_FETCH_FAILED: 'COURSES_FETCH_FAILED',
ORG_PLAN_CREATE_FAILED: 'ORG_PLAN_CREATE_FAILED',
ORG_PLAN_UPDATE_FAILED: 'ORG_PLAN_UPDATE_FAILED',
ORG_PLAN_CANCEL_FAILED: 'ORG_PLAN_CANCEL_FAILED',
ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND',
ORG_TEAM_INVITE_FAILED: 'ORG_TEAM_INVITE_FAILED',
ORG_TEAM_REMOVE_FAILED: 'ORG_TEAM_REMOVE_FAILED',
ORG_AUDIENCE_REMOVE_FAILED: 'ORG_AUDIENCE_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',
AUTOMATION_KEY_LIMIT_EXCEEDED: 'AUTOMATION_KEY_LIMIT_EXCEEDED',
AUTOMATION_CREDIT_LIMIT_EXCEEDED: 'AUTOMATION_CREDIT_LIMIT_EXCEEDED',
AUTOMATION_RATE_LIMIT_EXCEEDED: 'AUTOMATION_RATE_LIMIT_EXCEEDED',
TAG_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND',
TAG_NOT_FOUND: 'TAG_NOT_FOUND',
TAG_FETCH_FAILED: 'TAG_FETCH_FAILED',
TAG_CREATE_FAILED: 'TAG_CREATE_FAILED',
TAG_UPDATE_FAILED: 'TAG_UPDATE_FAILED',
TAG_ASSIGNMENT_FAILED: 'TAG_ASSIGNMENT_FAILED',
// Asset errors
ASSET_NOT_FOUND: 'ASSET_NOT_FOUND',
ASSET_IN_USE: 'ASSET_IN_USE',
ASSET_USAGE_NOT_FOUND: 'ASSET_USAGE_NOT_FOUND',
ASSET_CREATE_FAILED: 'ASSET_CREATE_FAILED',
ASSET_FETCH_FAILED: 'ASSET_FETCH_FAILED',
ASSET_LIST_FAILED: 'ASSET_LIST_FAILED',
ASSET_UPDATE_FAILED: 'ASSET_UPDATE_FAILED',
ASSET_DELETE_FAILED: 'ASSET_DELETE_FAILED',
ASSET_ATTACH_FAILED: 'ASSET_ATTACH_FAILED',
ASSET_DETACH_FAILED: 'ASSET_DETACH_FAILED',
ASSET_USAGE_FETCH_FAILED: 'ASSET_USAGE_FETCH_FAILED',
ASSET_EXPORT_FAILED: 'ASSET_EXPORT_FAILED',
ASSET_STORAGE_FETCH_FAILED: 'ASSET_STORAGE_FETCH_FAILED',
// Profile errors
PROFILE_UPDATE_FAILED: 'PROFILE_UPDATE_FAILED',
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
// Account errors
ACCOUNT_NOT_FOUND: 'ACCOUNT_NOT_FOUND',
// Auth errors
FORBIDDEN: 'FORBIDDEN',
// 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',
LESSON_FETCH_FAILED: 'LESSON_FETCH_FAILED',
COURSE_SECTION_NOT_FOUND: 'COURSE_SECTION_NOT_FOUND',
// Exercise errors
EXERCISE_NOT_FOUND: 'EXERCISE_NOT_FOUND',
EXERCISE_FETCH_FAILED: 'EXERCISE_FETCH_FAILED',
// Submission errors
SUBMISSION_NOT_FOUND: 'SUBMISSION_NOT_FOUND',
SUBMISSION_FETCH_FAILED: 'SUBMISSION_FETCH_FAILED',
// Comment errors
COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND',
LESSON_COMMENT_UPDATE_FAILED: 'LESSON_COMMENT_UPDATE_FAILED',
NEWSFEED_COMMENT_UPDATE_FAILED: 'NEWSFEED_COMMENT_UPDATE_FAILED',
// Quiz errors
QUIZ_NOT_FOUND: 'QUIZ_NOT_FOUND',
QUIZ_FETCH_FAILED: 'QUIZ_FETCH_FAILED',
QUIZ_CREATE_FAILED: 'QUIZ_CREATE_FAILED',
QUIZ_UPDATE_FAILED: 'QUIZ_UPDATE_FAILED',
QUIZ_DELETE_FAILED: 'QUIZ_DELETE_FAILED',
// Lesson Language errors
LESSON_LANGUAGE_NOT_FOUND: 'LESSON_LANGUAGE_NOT_FOUND',
LESSON_LANGUAGE_FETCH_FAILED: 'LESSON_LANGUAGE_FETCH_FAILED',
LESSON_LANGUAGE_CREATE_FAILED: 'LESSON_LANGUAGE_CREATE_FAILED',
LESSON_LANGUAGE_UPDATE_FAILED: 'LESSON_LANGUAGE_UPDATE_FAILED',
// SSO errors
SSO_REGISTRATION_FAILED: 'SSO_REGISTRATION_FAILED',
SSO_NOT_FOUND: 'SSO_NOT_FOUND',
SSO_NOT_ENABLED: 'SSO_NOT_ENABLED',
UPGRADE_REQUIRED: 'UPGRADE_REQUIRED',
CONFLICT: 'CONFLICT',
// Token exchange errors
TOKEN_EXCHANGE_INVALID_TOKEN: 'TOKEN_EXCHANGE_INVALID_TOKEN',
TOKEN_EXCHANGE_EXPIRED: 'TOKEN_EXCHANGE_EXPIRED',
TOKEN_EXCHANGE_ORG_NOT_FOUND: 'TOKEN_EXCHANGE_ORG_NOT_FOUND',
TOKEN_EXCHANGE_NOT_ENABLED: 'TOKEN_EXCHANGE_NOT_ENABLED',
// Token auth admin errors
TOKEN_AUTH_ALREADY_EXISTS: 'TOKEN_AUTH_ALREADY_EXISTS',
// Generic
VALIDATION_ERROR: 'VALIDATION_ERROR',
INTERNAL_ERROR: 'INTERNAL_ERROR'
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
/**
* Handles errors consistently across all routes
* @param c - Hono context
+1
View File
@@ -11,6 +11,7 @@ type LessonDocument = NonNullable<TLesson['documents']>[number];
function mapProviderToVideoType(provider: string): LessonVideo['type'] {
if (provider === 'youtube') return 'youtube';
if (provider === 'google_drive') return 'google_drive';
if (provider === 'generic' || provider === 'external_url') return 'generic';
return 'upload';
}
+14 -3
View File
@@ -1,5 +1,4 @@
/** Normalize JSONB organization-invite metadata for course ids (matches importAudienceMembers storage). */
export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
function parseInviteIds(metadata: unknown, keys: string[]): string[] {
let obj: Record<string, unknown> | null = null;
if (metadata == null) {
return [];
@@ -17,9 +16,21 @@ export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
if (!obj) {
return [];
}
const raw = obj.courseIds ?? obj.course_ids;
const raw = keys.map((key) => obj[key]).find((value) => value !== undefined);
if (!Array.isArray(raw)) {
return [];
}
return raw.filter((id): id is string => typeof id === 'string' && id.length > 0);
}
/** Normalize JSONB organization-invite metadata for course ids (matches importAudienceMembers storage). */
export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
return parseInviteIds(metadata, ['courseIds', 'course_ids']);
}
/** Normalize JSONB organization-invite metadata for program ids (matches importAudienceMembers storage). */
export function parseProgramIdsFromInviteMetadata(metadata: unknown): string[] {
return parseInviteIds(metadata, ['programIds', 'program_ids']);
}
+18 -3
View File
@@ -3,8 +3,17 @@
* These functions help create consistent and secure rate limit keys
*/
import { getConnInfo } from '@hono/node-server/conninfo';
import type { Context } from 'hono';
function normalizeIp(value: string | null | undefined): string | null {
const ip = value?.trim();
if (!ip) return null;
return ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip;
}
/**
* Extract real client IP address considering various proxy headers
*/
@@ -15,9 +24,11 @@ export const extractClientIp = (c: Context): string => {
// 3. X-Forwarded-For (standard, but can be spoofed)
// 4. Connection IP (least reliable)
const cfConnectingIp = c.req.header('cf-connecting-ip');
const realIp = c.req.header('x-real-ip');
const cfConnectingIp = normalizeIp(c.req.header('cf-connecting-ip'));
const realIp = normalizeIp(c.req.header('x-real-ip'));
const forwardedFor = c.req.header('x-forwarded-for');
const connInfo = getConnInfo(c);
const remoteAddress = normalizeIp(connInfo.remote.address);
if (cfConnectingIp) {
return cfConnectingIp;
@@ -30,7 +41,11 @@ export const extractClientIp = (c: Context): string => {
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs: "client, proxy1, proxy2"
// The first IP is usually the original client
return forwardedFor.split(',')[0].trim();
return normalizeIp(forwardedFor.split(',')[0]) ?? 'unknown';
}
if (remoteAddress) {
return remoteAddress;
}
// Fallback - this might be a proxy IP