diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 80d41bab..a7d362d5 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -2,6 +2,12 @@ name: Publish @classroomio/mcp to NPM on: workflow_dispatch: + pull_request: + branches: + - feat/release-v2 + paths: + - ".github/workflows/publish-mcp.yml" + - "packages/mcp/**" jobs: publish: @@ -15,7 +21,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20.19.3 - registry-url: 'https://registry.npmjs.org' + registry-url: "https://registry.npmjs.org" - uses: pnpm/action-setup@v4 @@ -26,6 +32,7 @@ jobs: run: pnpm --filter @classroomio/mcp build - name: Publish package + if: github.event_name == 'workflow_dispatch' run: pnpm --filter @classroomio/mcp publish --no-git-checks --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/PRD-TRACKER.md b/PRD-TRACKER.md index b280dfb5..388a1102 100644 --- a/PRD-TRACKER.md +++ b/PRD-TRACKER.md @@ -15,6 +15,7 @@ Use this file to keep a single prioritized view of all PRDs. | 7 | 13 | `prd/self-host-single-org.md` | done | | 8 | 7 | `prd/email-template-customization` | todo | | 9 | - | `prd/selective-exercise-assignment` | todo | +| - | - | `prd/mcp-course-authoring` | todo | | - | 1 | `prd/ai-course-assistant` | todo | | - | 2 | `prd/compliance-training-platform` | todo | | - | 4 | `prd/course-content-search` | todo | diff --git a/apps/api/src/middlewares/auth-or-automation-key.ts b/apps/api/src/middlewares/auth-or-automation-key.ts new file mode 100644 index 00000000..db185c42 --- /dev/null +++ b/apps/api/src/middlewares/auth-or-automation-key.ts @@ -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 + ); +}; diff --git a/apps/api/src/middlewares/automation-key.ts b/apps/api/src/middlewares/automation-key.ts new file mode 100644 index 00000000..19c2f829 --- /dev/null +++ b/apps/api/src/middlewares/automation-key.ts @@ -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 + ); + } +}; diff --git a/apps/api/src/middlewares/org-admin-or-api-key.ts b/apps/api/src/middlewares/org-admin-or-api-key.ts new file mode 100644 index 00000000..ed3a347c --- /dev/null +++ b/apps/api/src/middlewares/org-admin-or-api-key.ts @@ -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 + ); + } +}; diff --git a/apps/api/src/middlewares/org-admin-or-automation-key.ts b/apps/api/src/middlewares/org-admin-or-automation-key.ts new file mode 100644 index 00000000..a218701d --- /dev/null +++ b/apps/api/src/middlewares/org-admin-or-automation-key.ts @@ -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 + ); + } + }; diff --git a/apps/api/src/routes/organization/automation.ts b/apps/api/src/routes/organization/automation.ts new file mode 100644 index 00000000..bc567d45 --- /dev/null +++ b/apps/api/src/routes/organization/automation.ts @@ -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'); + } + } + ); diff --git a/apps/api/src/routes/organization/course-import.ts b/apps/api/src/routes/organization/course-import.ts new file mode 100644 index 00000000..4df2bb29 --- /dev/null +++ b/apps/api/src/routes/organization/course-import.ts @@ -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'); + } + } + ); diff --git a/apps/api/src/routes/organization/index.ts b/apps/api/src/routes/organization/index.ts index b7494258..aad8d306 100644 --- a/apps/api/src/routes/organization/index.ts +++ b/apps/api/src/routes/organization/index.ts @@ -2,3 +2,5 @@ export * from './organization'; export * from './assets'; export * from './tags'; export * from './token-auth'; +export * from './course-import'; +export * from './automation'; diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts index 1a468092..93c7e5fa 100644 --- a/apps/api/src/routes/organization/organization.ts +++ b/apps/api/src/routes/organization/organization.ts @@ -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); diff --git a/apps/api/src/services/course-import/course-import.ts b/apps/api/src/services/course-import/course-import.ts new file mode 100644 index 00000000..2112ee93 --- /dev/null +++ b/apps/api/src/services/course-import/course-import.ts @@ -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(); + 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(); + 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 + ); + } +} diff --git a/apps/api/src/services/course/invite.ts b/apps/api/src/services/course/invite.ts index 58886d09..07a3dde8 100644 --- a/apps/api/src/services/course/invite.ts +++ b/apps/api/src/services/course/invite.ts @@ -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 { + 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 { + 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 } }; } diff --git a/apps/api/src/services/organization/automation-key.ts b/apps/api/src/services/organization/automation-key.ts new file mode 100644 index 00000000..3eb3aa40 --- /dev/null +++ b/apps/api/src/services/organization/automation-key.ts @@ -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 = { + 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; + +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 { + 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 { + 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 { + await updateOrganizationApiKey(key.organizationId, key.id, { + lastUsedAt: new Date().toISOString() + }); +} diff --git a/apps/api/src/types/auth.ts b/apps/api/src/types/auth.ts index 2415f91a..2fd1974e 100644 --- a/apps/api/src/types/auth.ts +++ b/apps/api/src/types/auth.ts @@ -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; diff --git a/apps/api/src/utils/errors.ts b/apps/api/src/utils/errors.ts index 9678807c..c6b9db14 100644 --- a/apps/api/src/utils/errors.ts +++ b/apps/api/src/utils/errors.ts @@ -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', diff --git a/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts b/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts new file mode 100644 index 00000000..f607025e --- /dev/null +++ b/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts @@ -0,0 +1,79 @@ +import type { + AutomationKey, + AutomationKeyType, + CreateAutomationKeyRequest, + ListAutomationKeysRequest, + RevokeAutomationKeyRequest, + RotateAutomationKeyRequest +} from '$features/automation/utils/types'; +import { getDefaultAutomationKeyLabel } from '$features/automation/utils/automation-utils'; +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; + +import { snackbar } from '$features/ui/snackbar/store'; + +class AutomationApi extends BaseApiWithErrors { + generatedSecret = $state(null); + isFetched = $state(false); + keys = $state([]); + + async listKeys(type?: AutomationKeyType) { + return this.execute({ + requestFn: () => + classroomio.organization.automation.keys.$get({ + query: type ? { type } : {} + }), + logContext: 'fetching automation keys', + onSuccess: (response) => { + this.keys = response.data; + this.isFetched = true; + } + }); + } + + async createKey(type: AutomationKeyType, label?: string) { + return this.execute({ + requestFn: () => + classroomio.organization.automation.keys.$post({ + json: { + type, + label: label?.trim() || getDefaultAutomationKeyLabel(type) + } + }), + logContext: 'creating automation key', + onSuccess: (response) => { + this.generatedSecret = response.data.secret; + this.keys = [response.data.key, ...this.keys]; + snackbar.success('snackbar.automation.created'); + } + }); + } + + async revokeKey(keyId: string) { + return this.execute({ + requestFn: () => classroomio.organization.automation.keys[':keyId'].$delete({ param: { keyId } }), + logContext: 'revoking automation key', + onSuccess: (response) => { + this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key)); + snackbar.success('snackbar.automation.revoked'); + } + }); + } + + async rotateKey(keyId: string) { + return this.execute({ + requestFn: () => classroomio.organization.automation.keys[':keyId'].rotate.$post({ param: { keyId } }), + logContext: 'rotating automation key', + onSuccess: (response) => { + this.generatedSecret = response.data.secret; + this.keys = this.keys.map((key) => (key.id === response.data.key.id ? response.data.key : key)); + snackbar.success('snackbar.automation.rotated'); + } + }); + } + + clearGeneratedSecret() { + this.generatedSecret = null; + } +} + +export const automationApi = new AutomationApi(); diff --git a/apps/dashboard/src/lib/features/automation/pages/automation.svelte b/apps/dashboard/src/lib/features/automation/pages/automation.svelte new file mode 100644 index 00000000..13d0298f --- /dev/null +++ b/apps/dashboard/src/lib/features/automation/pages/automation.svelte @@ -0,0 +1,269 @@ + + + + {#if !$isOrgAdmin} + + {/if} + + + + {$t('automation.tabs.mcp')} + + {$t('automation.tabs.zapier')} + {$t('automation.coming_soon')} + + + {$t('automation.tabs.api')} + {$t('automation.coming_soon')} + + + + + + + + {$t('automation.mcp.keys.title')} + + {$t('automation.mcp.keys.description')} + +
+ +
+ + {#if automationApi.generatedSecret} +
+

{$t('automation.mcp.keys.secret_once')}

+
+ + + +
+
+ {/if} + + {#if $isOrgAdmin} + {#if mcpKeys.length > 0} +
+ {#each mcpKeys as key (key.id)} + + +
+
+ {key.label} + {getMaskedAutomationSecret(key.secretPrefix)} +
+ + {key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')} + +
+
+ +

+ {$t('automation.keys.created_at_label')} + {new Date(key.createdAt).toLocaleString()} +

+

+ {$t('automation.keys.last_used_label')} + {key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : $t('automation.keys.never_used')} +

+

+ {$t('automation.keys.scopes_label')} + {key.scopes.join(', ')} +

+
+ + + + +
+ {/each} +
+ {:else} + + {/if} + {/if} +
+ + + + + + + {$t('automation.mcp.setup.title')} + + {$t('automation.mcp.setup.description')} + + + + {$t('automation.clients.claude_code')} + {$t('automation.clients.codex')} + {$t('automation.clients.cursor')} + + + + + + {$t('automation.clients.claude_code')} + {$t('automation.mcp.setup.claude_code_description')} + + + + + + + + + + + + + + + + {$t('automation.clients.codex')} + {$t('automation.mcp.setup.codex_description')} + + + + + + + + + + + + + + + + {$t('automation.clients.cursor')} + {$t('automation.mcp.setup.cursor_description')} + + + + + + + + + + + + + +
+ + + + + + + + +
+
diff --git a/apps/dashboard/src/lib/features/automation/pages/index.ts b/apps/dashboard/src/lib/features/automation/pages/index.ts new file mode 100644 index 00000000..761fd2d8 --- /dev/null +++ b/apps/dashboard/src/lib/features/automation/pages/index.ts @@ -0,0 +1 @@ +export { default as AutomationPage } from './automation.svelte'; diff --git a/apps/dashboard/src/lib/features/automation/utils/automation-utils.ts b/apps/dashboard/src/lib/features/automation/utils/automation-utils.ts new file mode 100644 index 00000000..6c13ba85 --- /dev/null +++ b/apps/dashboard/src/lib/features/automation/utils/automation-utils.ts @@ -0,0 +1,78 @@ +import type { AutomationKeyType } from './types'; + +function getServerPath() { + return 'npx -y @classroomio/mcp'; +} + +export function getAutomationSetupSecret(secret: string | null) { + return secret ?? ''; +} + +export function getClaudeCodeSnippet(secret: string | null) { + const apiKey = getAutomationSetupSecret(secret); + + return `claude mcp add-json classroomio '{ + "command": "npx", + "args": ["-y", "@classroomio/mcp"], + "env": { + "CLASSROOMIO_API_URL": "https://api.classroomio.com", + "CLASSROOMIO_API_KEY": "${apiKey}" + } +}'`; +} + +export function getCodexSnippet(secret: string | null) { + const apiKey = getAutomationSetupSecret(secret); + + return `codex mcp add classroomio \\ + --env CLASSROOMIO_API_URL=https://api.classroomio.com \\ + --env CLASSROOMIO_API_KEY=${apiKey} \\ + -- npx -y @classroomio/mcp`; +} + +export function getCursorSnippet(secret: string | null) { + const apiKey = getAutomationSetupSecret(secret); + + return `{ + "mcpServers": { + "classroomio": { + "command": "npx", + "args": ["-y", "@classroomio/mcp"], + "env": { + "CLASSROOMIO_API_URL": "https://api.classroomio.com", + "CLASSROOMIO_API_KEY": "${apiKey}" + } + } + } +}`; +} + +export function getDefaultAutomationKeyLabel(type: AutomationKeyType) { + switch (type) { + case 'mcp': + return 'ClassroomIO MCP'; + case 'api': + return 'ClassroomIO API'; + case 'zapier': + return 'ClassroomIO Zapier'; + } +} + +export function getAutomationKeyTypeLabel(type: AutomationKeyType) { + switch (type) { + case 'mcp': + return 'MCP'; + case 'api': + return 'API'; + case 'zapier': + return 'Zapier'; + } +} + +export function getMaskedAutomationSecret(prefix: string) { + return `${prefix}...`; +} + +export function getCopyableServerCommand() { + return getServerPath(); +} diff --git a/apps/dashboard/src/lib/features/automation/utils/types.ts b/apps/dashboard/src/lib/features/automation/utils/types.ts new file mode 100644 index 00000000..0226f0bf --- /dev/null +++ b/apps/dashboard/src/lib/features/automation/utils/types.ts @@ -0,0 +1,15 @@ +import { classroomio, type InferResponseType } from '$lib/utils/services/api'; + +export type ListAutomationKeysRequest = (typeof classroomio.organization)['automation']['keys']['$get']; +export type CreateAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys']['$post']; +export type RevokeAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys'][':keyId']['$delete']; +export type RotateAutomationKeyRequest = + (typeof classroomio.organization)['automation']['keys'][':keyId']['rotate']['$post']; + +export type ListAutomationKeysSuccess = Extract, { success: true }>; +export type CreateAutomationKeySuccess = Extract, { success: true }>; +export type RevokeAutomationKeySuccess = Extract, { success: true }>; +export type RotateAutomationKeySuccess = Extract, { success: true }>; + +export type AutomationKey = ListAutomationKeysSuccess['data'][number]; +export type AutomationKeyType = AutomationKey['type']; diff --git a/apps/dashboard/src/lib/features/community/api/community.svelte.ts b/apps/dashboard/src/lib/features/community/api/community.svelte.ts index 35447c41..9cad5d0c 100644 --- a/apps/dashboard/src/lib/features/community/api/community.svelte.ts +++ b/apps/dashboard/src/lib/features/community/api/community.svelte.ts @@ -14,7 +14,7 @@ import { currentOrg as currentOrgStore } from '$lib/utils/store/org'; import type { TCreateCommunityQuestion } from '@cio/utils/validation/community'; import { coursesApi } from '$features/course/api/courses.svelte'; import { currentCommunityQuestion } from '../utils/store'; -import generateSlug from '$lib/utils/functions/generateSlug'; +import { generateSlug } from '@cio/utils/functions'; import { get } from 'svelte/store'; import { basePath, isStudentExperience } from '$lib/utils/store/app'; import { goto } from '$app/navigation'; @@ -121,7 +121,7 @@ class CommunityApi extends BaseApiWithErrors { return; } - const slug = generateSlug(fields.title); + const slug = generateSlug(fields.title, { appendTimestamp: true }); const currentOrg = get(currentOrgStore); const profile = get(profileStore); diff --git a/apps/dashboard/src/lib/features/course/api/course-clone.svelte.ts b/apps/dashboard/src/lib/features/course/api/course-clone.svelte.ts index d7028a97..78f77ea2 100644 --- a/apps/dashboard/src/lib/features/course/api/course-clone.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/course-clone.svelte.ts @@ -5,7 +5,7 @@ import type { CloneCourseRequest } from '../utils/types'; import type { TCourseClone } from '@cio/utils/validation/course'; import { ZCourseClone } from '@cio/utils/validation/course'; import { currentOrg } from '$lib/utils/store/org'; -import generateSlug from '$lib/utils/functions/generateSlug'; +import { generateSlug } from '@cio/utils/functions'; import { get } from 'svelte/store'; import { goto } from '$app/navigation'; import { mapZodErrorsToTranslations } from '$lib/utils/validation'; @@ -26,7 +26,7 @@ export class CourseCloneApi extends BaseApiWithErrors { */ async clone(courseId: string, title: string, description?: string) { // Generate unique slug from the title - const slug = generateSlug(title); + const slug = generateSlug(title, { appendTimestamp: true }); // Prepare data for validation const data: TCourseClone = { diff --git a/apps/dashboard/src/lib/features/course/pages/exercise.svelte b/apps/dashboard/src/lib/features/course/pages/exercise.svelte index 95943b49..84c63be3 100644 --- a/apps/dashboard/src/lib/features/course/pages/exercise.svelte +++ b/apps/dashboard/src/lib/features/course/pages/exercise.svelte @@ -206,7 +206,7 @@ const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false); - + {$questionnaire.title || 'Exercise'} diff --git a/apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts b/apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts index f49c8c0e..b9cedb0c 100644 --- a/apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts +++ b/apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts @@ -10,6 +10,7 @@ import { import type { AccountOrg } from '$features/app/types'; import type { Component } from 'svelte'; +import BracesIcon from '@lucide/svelte/icons/braces'; import TagIcon from '@lucide/svelte/icons/tag'; import { isActive } from '$lib/utils/functions/app'; @@ -22,6 +23,7 @@ export interface NavItem { isExpanded?: boolean; items?: NavItem[]; // for nested items like settings isPaid?: boolean; // Show upgrade indicator for free plan users + disabled?: boolean; // Metadata for breadcrumb generation useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings) nestedRoutes?: NestedRouteConfig[]; // Static nested routes (like community/ask, settings/customize-lms) @@ -33,6 +35,7 @@ export interface NavItemConfig { path: string; icon?: Component; requiresAdmin?: boolean; + disableWhenNotAdmin?: boolean; items?: NavItemConfig[]; useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings) nestedRoutes?: NestedRouteConfig[]; // Static nested routes @@ -99,6 +102,14 @@ const baseNavConfig: NavItemConfig[] = [ requiresAdmin: true, matchPattern: '^/org/[^/]+/setup(/.*)?$' // Matches nested routes }, + { + titleKey: 'org_navigation.automation', + path: '/automation', + icon: BracesIcon, + requiresAdmin: true, + disableWhenNotAdmin: true, + matchPattern: '^/org/[^/]+/automation(/.*)?$' + }, { titleKey: 'org_navigation.settings', path: '/settings', @@ -172,7 +183,7 @@ export function getOrgNavigationItems( for (const config of baseNavConfig) { // Skip admin-only items if user is not admin - if (config.requiresAdmin && !isOrgAdmin) { + if (config.requiresAdmin && !isOrgAdmin && !config.disableWhenNotAdmin) { continue; } @@ -190,6 +201,7 @@ export function getOrgNavigationItems( icon: config.icon, isActive: isActive(pagePathname, fullPath, matchPattern), isExpanded: config.items ? isActive(pagePathname, fullPath, matchPattern) : undefined, + disabled: Boolean(config.disableWhenNotAdmin && !isOrgAdmin), useHashUrl: config.useHashUrl, nestedRoutes: config.nestedRoutes, supportsDynamicSegment: config.supportsDynamicSegment, diff --git a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/nav-main.svelte b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/nav-main.svelte index ec707404..24939aac 100644 --- a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/nav-main.svelte +++ b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/nav-main.svelte @@ -27,21 +27,37 @@ {#snippet child({ props })} {#snippet children(isHovered)} - - {#if item.icon} - {@const Icon = item.icon} - + {#if item.disabled} + + {#if item.icon} + {@const Icon = item.icon} + + {item.title} + {:else} + {item.title} + {/if} + + {:else} + + {#if item.icon} + {@const Icon = item.icon} + - {item.title} - {:else} - {item.title} - {/if} - {#if item.items} - - {/if} - + {item.title} + {:else} + {item.title} + {/if} + {#if item.items} + + {/if} + + {/if} {/snippet} {/snippet} diff --git a/apps/dashboard/src/lib/utils/constants/routes.ts b/apps/dashboard/src/lib/utils/constants/routes.ts index b8b12b56..b4519356 100644 --- a/apps/dashboard/src/lib/utils/constants/routes.ts +++ b/apps/dashboard/src/lib/utils/constants/routes.ts @@ -29,8 +29,7 @@ export const PUBLIC_ROUTES = [ ROUTE.FORGOT, ROUTE.RESET, `^${ROUTE.PAGES}/.*`, - // Course landing page only (excludes COURSE_ENROLL which requires auth) - `^${ROUTE.COURSE}/[^/]+/?$`, + `^/course/.*/enroll$`, `^${ROUTE.COURSES}/?$`, '/404', `^${ROUTE.VERIFY_EMAIL_ERROR}$`, diff --git a/apps/dashboard/src/lib/utils/functions/generateSlug.ts b/apps/dashboard/src/lib/utils/functions/generateSlug.ts deleted file mode 100644 index fdaee1c4..00000000 --- a/apps/dashboard/src/lib/utils/functions/generateSlug.ts +++ /dev/null @@ -1,10 +0,0 @@ -function generateSlug(title) { - return title - .match(/[^\W\s\d]*/g, '') - .filter((v = '') => !!v.trim()) - .join('-') - .toLowerCase() - .concat(`-${new Date().getTime()}`); -} - -export default generateSlug; diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index 6f034df6..898bc7b6 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -2474,32 +2474,17 @@ "subtitle": "Forbind MCP-klienter nu, med Zapier og den offentlige API, der følger på det samme nøglesystem med organisationsomfang.", "coming_soon": "Kommer snart", "mcp": { - "architecture": { - "title": "Arkitektur", - "description": "MCP-klienten og ClassroomIO API er separate tillidsgrænser. Den org-omfattede automatiseringsnøgle autentificerer klienten, og API'et forbliver kilden til sandhed for validering og vedholdenhed." - }, - "flows": { - "title": "Typiske flows", - "description": "Agenten ejer ræsonnement og struktur. ClassroomIO accepterer kun normaliserede kurser, gemmer kladder og udgiver efter anmodning.", - "prompt_title": "Spørg om udkast", - "prompt_description": "Brugeren beder om et kursus, agenten strukturerer det, og 'create_course_draft' gemmer det i ClassroomIO før ethvert udgivelsestrin.", - "pdf_title": "PDF til udkast", - "pdf_description": "Agenten læser PDF'en eksternt, omstrukturerer den i sektioner og lektioner og sender derefter den normaliserede kladde til ClassroomIO.", - "review_title": "Anmeldelse for at offentliggøre", - "review_description": "Brugeren beder om ændringer, agenten opdaterer kladden, og publicering sker kun efter eksplicit godkendelse." - }, "setup": { "title": "Klient opsætning", "description": "Brug én nøgle med organisationsomfang pr. integration. Hvis en nøgle roteres eller tilbagekaldes, holder klienten op med at arbejde, indtil den nye hemmelighed er konfigureret.", - "claude_code_description": "Registrerer `@cio/mcp` med Claude Code ved hjælp af den genererede org-omfangede nøgle.", + "claude_code_description": "Registrerer `@classroomio/mcp` med Claude Code ved hjælp af den genererede org-omfangede nøgle.", "codex_description": "Tilføjer ClassroomIO MCP-serveren til Codex med den samme org-omfattede nøgle.", "cursor_description": "Indsæt dette i `~/.cursor/mcp.json` eller dit arbejdsområde MCP-konfiguration." }, "keys": { "title": "MCP nøgler", - "description": "Disse nøgler autentificerer den aktuelle organisation, når en MCP-klient ringer til ClassroomIO gennem `@cio/mcp`.", + "description": "Disse nøgler autentificerer den aktuelle organisation, når en MCP-klient ringer til ClassroomIO gennem `@classroomio/mcp`.", "generate": "Generer MCP-nøgle", - "scope_badge": "Udkast opret/læs/opdater/udgiv", "secret_once": "Denne hemmelighed vises én gang. Kopier det til din MCP-klient nu." } }, @@ -2530,10 +2515,6 @@ "permissions": { "disabled_title": "Skrivebeskyttet adgang", "disabled_description": "Kun organisationsadministratorer kan oprette, rotere eller tilbagekalde automatiseringsnøgler. Lærere kan gennemgå opsætningsinstruktionerne her." - }, - "overview": { - "title": "Automatiseringsadgang", - "description": "Opret org-omfattede automatiseringsnøgler, tilslut understøttede klienter, og hold al maskinadgang bundet tilbage til denne organisation." } } } diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 72f4d543..2ae3df3b 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -2474,32 +2474,17 @@ "subtitle": "Verbinden Sie jetzt MCP-Clients, wobei Zapier und die öffentliche API auf demselben organisationsweiten Schlüsselsystem folgen.", "coming_soon": "Kommt bald", "mcp": { - "architecture": { - "title": "Architektur", - "description": "Der MCP-Client und die ClassroomIO-API sind separate Vertrauensgrenzen. Der organisationsweite Automatisierungsschlüssel authentifiziert den Client und die API bleibt die Quelle der Wahrheit für Validierung und Persistenz." - }, - "flows": { - "title": "Typische Strömungen", - "description": "Der Agent besitzt Argumentation und Struktur. ClassroomIO akzeptiert nur normalisierte Kursnutzlasten, speichert Entwürfe und veröffentlicht auf Anfrage.", - "prompt_title": "Aufforderung zum Entwurf", - "prompt_description": "Der Benutzer fragt nach einem Kurs, der Agent strukturiert ihn und „create_course_draft“ speichert ihn dann vor jedem Veröffentlichungsschritt in ClassroomIO.", - "pdf_title": "PDF zum Entwurf", - "pdf_description": "Der Agent liest das PDF extern, strukturiert es in Abschnitte und Lektionen um und sendet dann den normalisierten Entwurf an ClassroomIO.", - "review_title": "Rezension zur Veröffentlichung", - "review_description": "Der Benutzer bittet um Änderungen, der Agent aktualisiert den Entwurf und die Veröffentlichung erfolgt erst nach ausdrücklicher Genehmigung." - }, "setup": { "title": "Client-Setup", "description": "Verwenden Sie einen organisationsweiten Schlüssel pro Integration. Wenn ein Schlüssel rotiert oder widerrufen wird, funktioniert der Client nicht mehr, bis das neue Geheimnis konfiguriert ist.", - "claude_code_description": "Registriert „@cio/mcp“ bei Claude Code unter Verwendung des generierten organisationsweit gültigen Schlüssels.", + "claude_code_description": "Registriert „@classroomio/mcp“ bei Claude Code unter Verwendung des generierten organisationsweit gültigen Schlüssels.", "codex_description": "Fügt den ClassroomIO MCP-Server mit demselben organisationsweit gültigen Schlüssel zu Codex hinzu.", "cursor_description": "Fügen Sie dies in „~/.cursor/mcp.json“ oder Ihre Workspace-MCP-Konfiguration ein." }, "keys": { "title": "MCP-Schlüssel", - "description": "Diese Schlüssel authentifizieren die aktuelle Organisation, wenn ein MCP-Client ClassroomIO über „@cio/mcp“ aufruft.", + "description": "Diese Schlüssel authentifizieren die aktuelle Organisation, wenn ein MCP-Client ClassroomIO über „@classroomio/mcp“ aufruft.", "generate": "MCP-Schlüssel generieren", - "scope_badge": "Entwurf erstellen/lesen/aktualisieren/veröffentlichen", "secret_once": "Dieses Geheimnis wird einmal gezeigt. Kopieren Sie es jetzt in Ihren MCP-Client." } }, @@ -2530,10 +2515,6 @@ "permissions": { "disabled_title": "Nur lesender Zugriff", "disabled_description": "Nur Organisationsadministratoren können Automatisierungsschlüssel erstellen, rotieren oder widerrufen. Lehrer können die Einrichtungsanweisungen hier einsehen." - }, - "overview": { - "title": "Automatisierungszugriff", - "description": "Erstellen Sie organisationsweite Automatisierungsschlüssel, verbinden Sie unterstützte Clients und halten Sie den gesamten Maschinenzugriff an diese Organisation gebunden." } } } diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index b138b112..0fedc3e3 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -9,7 +9,6 @@ "seconds": "seconds", "snackbar_error": "error while sending code" }, - "email_verification": { "errors": { "invalid_token": { @@ -52,21 +51,18 @@ }, "security_footer": "Your account security is our top priority. All verification attempts are logged for security monitoring." }, - "delete_modal": { "label": "Delete", "content": "Are you sure?", "no": "No", "yes": "Yes" }, - "new_course_modal": { "live_class_label": "Live Class", "live_class_subtitle": "Ideal for bootcamps where lessons are time based", "self_paced_label": "Self Paced", "self_paced_subtitle": "Best for courses where students can take lessons at their own pace" }, - "onboarding": { "sell": "Sell courses online", "teach": "Teach my current students online", @@ -86,20 +82,17 @@ "back": "Back", "continue": "Continue" }, - "welcome_modal": { "we_at": "We at", "small_team": "are a small team with a large mission to empower educators with great software to help them make an impact in people's lives. Education is the most powerful gift you can give anyone, and it makes perfect sense to provide you with all the tools you need to give this GIFT to more people.", "thank_you": "Thank you for choosing us", "deeply_appreciate": "we deeply appreciate it and are at your service." }, - "app": { "search": { "placeholder": "Search" } }, - "login": { "welcome": "Welcome back", "email": "Your email", @@ -142,7 +135,6 @@ "timer": "Activity timer", "tic_tac": "Tictac toe", "see_more": "See more", - "whats_new": "What's new", "launch_week": "Launch week" }, @@ -365,7 +357,6 @@ "video": "Video", "image": "Image" }, - "show_banner": "Show banner", "hide_banner": "Hide banner" }, @@ -422,7 +413,6 @@ "twitter": "Twitter", "linkedin": "LinkedIn" }, - "custom_links": { "heading": "Custom Links", "hide_links": "Hide Links", @@ -458,7 +448,6 @@ "error": "An error occurred while opening billing" } }, - "media_manager": { "page_title": "Media Manager - ClassroomIO", "heading": "Media Manager", @@ -567,7 +556,6 @@ } } }, - "tags_admin": { "page_title": "Tags - ClassroomIO", "heading": "Tags", @@ -617,7 +605,6 @@ "no_groups": "Create a tag group before creating tags" } }, - "public_courses": { "page_title": "Courses - ClassroomIO", "heading": "Courses", @@ -633,7 +620,6 @@ "description": "Try clearing your filters or check back later." } }, - "org_navigation": { "platform": "Platform", "dashboard": "Dashboard", @@ -650,7 +636,6 @@ "unlock": "Unlock unlimited features and invest in our future", "upgrade": "Upgrade Now" }, - "pricing": { "plan_names": { "free": "Free", @@ -671,7 +656,6 @@ "per_org": "Per Organization" } }, - "setup": { "your_checklist": "Your Checklist", "get_started": "Get Started", @@ -711,7 +695,6 @@ "button_label": "Publish Course" } }, - "add_org": { "create_org": "Create Organization", "name": "Name of Organization", @@ -720,7 +703,6 @@ "sitename": "Sitename already exists", "error_organization": "Something went wrong while creating this organization. Please reload and try again" }, - "navigation": { "goto": "Go to", "courses": "Courses", @@ -739,12 +721,10 @@ "org_loading_state": "Taking you to your organization...", "add_organization": "+ Add Organization" }, - "markdown_editor": { "write": "Write", "preview": "Preview" }, - "components": { "quiz": { "title": "Quizzes", @@ -825,7 +805,6 @@ } } }, - "schedule": { "title": "Your Schedule", "essence": "The essence of design collaboration", @@ -833,7 +812,6 @@ "join": "Join Call", "no": "No Lesson on this day" }, - "dashboard": { "morning_heading": "Good Morning", "afternoon_heading": "Good Afternoon", @@ -868,7 +846,6 @@ "publish_first_course_description": "To track enrollments, publish a course and invite students", "publish_course": "Publish Course" }, - "ai": { "help_me": "Help me write", "placeholder": "Help me write a short description", @@ -878,7 +855,6 @@ "insert": "Insert", "can_you": "Can you please rephrase this" }, - "courses": { "heading": "Courses", "heading_button": "Create Course", @@ -947,7 +923,6 @@ } } }, - "course": { "navItems": { "nav_news_feed": "News Feed", @@ -972,7 +947,6 @@ "placeholder": "Search lessons or exercises", "empty": "No results found" }, - "navItem": { "news_feed": { "heading": "News Feed", @@ -1015,7 +989,6 @@ "fetching_error": "An error occured while fetching feed" } }, - "lessons": { "heading": "Lessons", "heading_v2": "Content", @@ -1078,7 +1051,6 @@ "no_tutor": "No tutor added", "body_header": "No lessons yet", "body_content": "Share your knowledge with the world by creating engaging lessons. Start by clicking on the Add button.", - "lesson_nav": { "materials": "Materials", "exercises": "Exercises" @@ -1179,7 +1151,6 @@ "upload_error_try_again": "Please try again with a valid document file", "try_again": "Try Again", "upgrade": "Upgrade your plan to upload documents", - "cancel_upload": "Cancel Upload", "upload_cancelled": "Upload cancelled by user", "downloading": "Downloading...", @@ -1197,7 +1168,6 @@ }, "button_done": "Done" }, - "exercises": { "heading": "All Exercises", "add_button": "Add", @@ -1554,7 +1524,6 @@ "load_more_btn": "Load more", "comment_error": "Failed to post comment. Please try again." }, - "polls": { "title": "Poll", "active_polls": "Active Polls", @@ -1562,7 +1531,6 @@ "loading": "Loading...", "no_polls": "No polls to display" }, - "poll": { "voted": "Voted", "created_by": "Created by", @@ -1591,7 +1559,6 @@ "restore_version": "Restore this version" } }, - "attendance": { "title": "Attendance", "present": "Present", @@ -1601,7 +1568,6 @@ "lesson": "Lesson", "no_student": "No Student Added" }, - "submissions": { "title": "Submitted Exercises", "submission_status": { @@ -1640,7 +1606,6 @@ "delete_success": "Successfully deleted submission" } }, - "marks": { "title": "Marks", "student": "Student", @@ -1652,7 +1617,6 @@ }, "no_student": "No Student Added" }, - "people": { "title": "People", "add": "Add", @@ -1761,7 +1725,6 @@ "remove": "Remove" } }, - "certificates": { "title": "Certificates", "certificate_settings": "Certificate Settings", @@ -1789,7 +1752,6 @@ "characters": "characters", "unexpected_error": "An unexpected error occurred" }, - "landing_page": { "editor": { "title": { @@ -1802,12 +1764,10 @@ "pricing": "Pricing", "certificate": "Certificate" }, - "save": "Save", "page_builder": "Page Builder", "section": "section", "display_section": "Display section", - "header_form": { "title": "Title", "description": "Description", @@ -1816,7 +1776,6 @@ "replace": "Replace", "helper": "Enter a link to a youtube video" }, - "reviews_form": { "add_reviews": "Add Reviews", "fullname": "Full Name", @@ -1838,11 +1797,9 @@ } } }, - "certificate_form": { "upload_template": "Certificate template" }, - "instructor_form": { "upload": "Instructor Logo", "name": "Instructor name", @@ -1852,7 +1809,6 @@ "about_placeholder": "A short background about the instructure", "total": "Total number of courses" }, - "pricing_form": { "currency": "Currency", "cost": "Cost", @@ -1864,7 +1820,6 @@ "gift": "About Gift" } }, - "upload_widget": { "title": "Banner Widget", "width": "Images wider than 1500 pixels work best.", @@ -1873,7 +1828,6 @@ "submit": "Submit", "no_images": "No images available." }, - "pricing_section": { "free": "Free", "not_accepting": "This course is currently not accepting new students.", @@ -1887,7 +1841,6 @@ "back_to_course": "Back to course", "join_course": "Join Course" }, - "course_content": "Course content", "modules": "modules", "lessons": "lessons", @@ -1923,7 +1876,6 @@ "explore": "Explore courses", "find": "Find courses you will love from best teachers all over the world", "find_course": "Find a course...", - "reviews_modal": { "title": "Reviews", "rating": "Rating", @@ -1932,11 +1884,9 @@ "instructor": "Instructor", "courses": "courses" }, - "analytics": { "title": "Analytics" }, - "settings": { "heading": "Settings", "cover_image": "Cover Image", @@ -1987,14 +1937,12 @@ "save": "Save Changes" } }, - "not_permitted": { "header": "No Permission", "body": "Unfortunately you don't have access to this course.", "button": "Go home" } }, - "community": { "title": "Community", "ask_button": "Ask Community", @@ -2027,7 +1975,6 @@ }, "all": "All" }, - "audience": { "title": "Audience", "export": "Export", @@ -2038,7 +1985,6 @@ "date_joined": "Date Joined", "upgrade": "Upgrade your plan to invite more students" }, - "analytics": { "title": "Analytics", "courses": "Courses", @@ -2098,7 +2044,6 @@ "no_data_default": "No data available", "no_data_default_description": "There is no data to display at this time." }, - "upgrade": { "certificate": "Upgrade your plan to generate certificates", "team": "Upgrade your plan to add team members", @@ -2110,15 +2055,10 @@ "required": "Upgrade Required", "enterprise_required": "This feature requires an Enterprise plan." }, - "automation": { "title": "Automation", "subtitle": "Connect MCP clients now, with Zapier and the public API following on the same org-scoped key system.", "coming_soon": "Coming soon", - "overview": { - "title": "Automation access", - "description": "Create org-scoped automation keys, connect supported clients, and keep all machine access tied back to this organization." - }, "permissions": { "disabled_title": "Read-only access", "disabled_description": "Only organization admins can create, rotate, or revoke automation keys. Teachers can review the setup instructions here." @@ -2150,35 +2090,19 @@ "mcp": { "keys": { "title": "MCP keys", - "description": "These keys authenticate the current organization when an MCP client calls ClassroomIO through `@cio/mcp`.", + "description": "These keys authenticate the current organization when an MCP client calls ClassroomIO through `@classroomio/mcp`.", "generate": "Generate MCP key", - "scope_badge": "Draft create/read/update/publish", "secret_once": "This secret is shown once. Copy it into your MCP client now." }, "setup": { "title": "Client setup", "description": "Use one org-scoped key per integration. If a key is rotated or revoked, the client stops working until the new secret is configured.", - "claude_code_description": "Registers `@cio/mcp` with Claude Code using the generated org-scoped key.", + "claude_code_description": "Registers `@classroomio/mcp` with Claude Code using the generated org-scoped key.", "codex_description": "Adds the ClassroomIO MCP server to Codex with the same org-scoped key.", "cursor_description": "Paste this into `~/.cursor/mcp.json` or your workspace MCP config." - }, - "flows": { - "title": "Typical flows", - "description": "The agent owns reasoning and structure. ClassroomIO only accepts normalized course payloads, saves drafts, and publishes on request.", - "prompt_title": "Prompt to draft", - "prompt_description": "The user asks for a course, the agent structures it, then `create_course_draft` saves it into ClassroomIO before any publish step.", - "pdf_title": "PDF to draft", - "pdf_description": "The agent reads the PDF externally, restructures it into sections and lessons, then sends the normalized draft into ClassroomIO.", - "review_title": "Review to publish", - "review_description": "The user asks for changes, the agent updates the draft, and publish only happens after explicit approval." - }, - "architecture": { - "title": "Architecture", - "description": "The MCP client and ClassroomIO API are separate trust boundaries. The org-scoped automation key authenticates the client, and the API remains the source of truth for validation and persistence." } } }, - "lms_navigation": { "home": "Home", "my_learning": "My Learning", @@ -2188,7 +2112,6 @@ "help": "Help", "settings": "Settings" }, - "my_learning": { "heading": "My Learning", "progress": "In progress", @@ -2199,7 +2122,6 @@ "not_completed": "No Course Completed", "any_course": "Any course that you complete will be displayed here" }, - "exercises": { "heading": "Exercises", "not_submitted": "Not Submitted", @@ -2208,13 +2130,11 @@ "graded": "Graded", "lesson": "Lesson" }, - "explore": { "heading": "Explore our courses", "empty_heading": "No course available", "empty_description": "There are currently no published course available" }, - "snackbar": { "success": "Success", "success_update": "Updated successfully", @@ -2251,7 +2171,6 @@ "thumbnail_too_large": "Thumbnail image is too large", "thumbnail_invalid": "Invalid thumbnail file" }, - "poll": { "error": { "creating_poll": "An error occurred while creating poll", @@ -2263,19 +2182,16 @@ "vote_once": "You can only vote once" } }, - "exercise": { "success": "Saved Successfully", "submission_updated": "Submission updated to", "error_fetching": "Fetching exercises failed, please reload the page" }, - "materials": { "apology": "We apologise, there was an error saving your lesson.", "update_translations": "Updating translations failed", "creating_new": "Creating new translations failed" }, - "newsfeed": { "success": { "edit": "Feed edited successfully", @@ -2287,7 +2203,6 @@ "creating": "creating feed" } }, - "people": { "error": { "missing_data": "Org data missing, please reload the page", @@ -2297,7 +2212,6 @@ "copied": "Copied to clipboard" } }, - "course_settings": { "success": { "download": "Download Complete", @@ -2315,7 +2229,6 @@ "failed_deletion": "Deletion Failed, please try again later" } }, - "lms": { "error": { "username_exists": "username already exists", @@ -2323,7 +2236,6 @@ "try_again": "Please try again" } }, - "team_members": { "invite_sent": "Invite Sent!", "user_exists": "User Already Exists", @@ -2331,7 +2243,6 @@ "remove_fail": "Failed to remove user, please try again", "remove_success": "Member removed successfully" }, - "tags": { "group_created": "Tag group created", "group_updated": "Tag group updated", @@ -2341,7 +2252,6 @@ "fetch_failed": "Failed to load tags", "save_failed": "Failed to save tag changes" }, - "landing_page_settings": { "error": { "file_size": "File Size must not be greater than 500kb", @@ -2353,7 +2263,6 @@ "complete": "Complete :)" } }, - "course": { "success": { "comment_deleted": "Comment Deleted", @@ -2371,13 +2280,11 @@ "feed_fetch_error": "An error occured while fetching feed" } }, - "attendance": { "error": { "reload": "Something isn't right. Please reload and take attendance again" } }, - "lessons": { "lesson_created": "Lesson created successfully", "lesson_create_failed": "Failed to create lesson", @@ -2410,28 +2317,23 @@ "exercise_not_complete": "Complete all exercises to mark as complete" } }, - "marks": { "error": "Error fetching marks" }, - "submissions": { "success": { "grading": "Grading saved and student notified" } }, - "invite": { "failed_join": "Joining failed, please contact your admin" }, - "community": { "error": { "try_again": "Error - Please try again later", "deleting_comments": "Error deleting comments", "deleting_question": "Error deleting question" }, - "success": { "question_updated": "Question Updated!", "comment_submitted": "Comment Submitted!", @@ -2439,31 +2341,25 @@ "question_submitted": "Question Submitted!" } }, - "signup": { "disposable": "The email you provided is disposable" }, - "upgrade": { "generate_fail": "Failed to generate success", "failed": "Failed, please try again later" }, - "setup": { "info_lesson": "You Need to Create a lesson", "info_course": "You need to create a course" }, - "generic": { "success_delete": "Deleted successfully", "try_again": "Error - Please try again later" } }, - "specialization": { "course_tag": "Specialization" }, - "invite": { "organization": { "page_title": "Join {orgName} on ClassroomIO", @@ -2481,18 +2377,15 @@ } } }, - "content": { "heading": "Internationalization in SvelteKit", "toggle_label": "Select Preferred Language" }, - "generic": { "locked": "Locked", "unlocked": "Unlocked", "loading": "Loading" }, - "validations": { "user_profile": { "email": "Invalid email address", @@ -2619,7 +2512,6 @@ "invalid_field": "Invalid value" } }, - "common": { "unsaved_changes": { "message": "You have unsaved changes. Are you sure you want to leave?" diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 2d7101ba..5e407a05 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -2474,32 +2474,17 @@ "subtitle": "Conecte los clientes de MCP ahora, con Zapier y la API pública siguiendo el mismo sistema de claves con ámbito de organización.", "coming_soon": "Próximamente", "mcp": { - "architecture": { - "title": "Arquitectura", - "description": "El cliente MCP y la API ClassroomIO son límites de confianza separados. La clave de automatización con ámbito de organización autentica al cliente y la API sigue siendo la fuente de verdad para la validación y la persistencia." - }, - "flows": { - "title": "Flujos típicos", - "description": "El agente posee razonamiento y estructura. ClassroomIO solo acepta cargas útiles de cursos normalizados, guarda borradores y publica a pedido.", - "prompt_title": "Solicitar borrador", - "prompt_description": "El usuario solicita un curso, el agente lo estructura y luego `create_course_draft` lo guarda en ClassroomIO antes de cualquier paso de publicación.", - "pdf_title": "PDF para borrador", - "pdf_description": "El agente lee el PDF externamente, lo reestructura en secciones y lecciones y luego envía el borrador normalizado a ClassroomIO.", - "review_title": "Revisar para publicar", - "review_description": "El usuario solicita cambios, el agente actualiza el borrador y la publicación solo se realiza después de una aprobación explícita." - }, "setup": { "title": "Configuración del cliente", "description": "Utilice una clave con ámbito de organización por integración. Si se rota o revoca una clave, el cliente deja de funcionar hasta que se configura el nuevo secreto.", - "claude_code_description": "Registra `@cio/mcp` con Claude Code utilizando la clave de alcance organizacional generada.", + "claude_code_description": "Registra `@classroomio/mcp` con Claude Code utilizando la clave de alcance organizacional generada.", "codex_description": "Agrega el servidor ClassroomIO MCP al Codex con la misma clave de ámbito de organización.", "cursor_description": "Pegue esto en `~/.cursor/mcp.json` o en la configuración MCP de su espacio de trabajo." }, "keys": { "title": "Claves MCP", - "description": "Estas claves autentican la organización actual cuando un cliente MCP llama a ClassroomIO a través de `@cio/mcp`.", + "description": "Estas claves autentican la organización actual cuando un cliente MCP llama a ClassroomIO a través de `@classroomio/mcp`.", "generate": "Generar clave MCP", - "scope_badge": "Borrador de creación/lectura/actualización/publicación", "secret_once": "Este secreto se muestra una vez. Cópielo en su cliente MCP ahora." } }, @@ -2530,10 +2515,6 @@ "permissions": { "disabled_title": "Acceso de solo lectura", "disabled_description": "Solo los administradores de la organización pueden crear, rotar o revocar claves de automatización. Los profesores pueden revisar las instrucciones de configuración aquí." - }, - "overview": { - "title": "Acceso a la automatización", - "description": "Cree claves de automatización específicas de la organización, conecte clientes compatibles y mantenga todo el acceso a las máquinas vinculado a esta organización." } } } diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 6903f25a..085e7b47 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -2474,32 +2474,17 @@ "subtitle": "Connectez les clients MCP maintenant, avec Zapier et l'API publique qui suivent sur le même système de clés à l'échelle de l'organisation.", "coming_soon": "Bientôt disponible", "mcp": { - "architecture": { - "title": "Architecture", - "description": "Le client MCP et l'API ClassroomIO sont des limites de confiance distinctes. La clé d'automatisation à l'échelle de l'organisation authentifie le client et l'API reste la source de vérité pour la validation et la persistance." - }, - "flows": { - "title": "Flux typiques", - "description": "L'agent possède le raisonnement et la structure. ClassroomIO n'accepte que les charges utiles de cours normalisées, enregistre les brouillons et publie sur demande.", - "prompt_title": "Invite à rédiger", - "prompt_description": "L'utilisateur demande un cours, l'agent le structure, puis `create_course_draft` l'enregistre dans ClassroomIO avant toute étape de publication.", - "pdf_title": "PDF à rédiger", - "pdf_description": "L'agent lit le PDF en externe, le restructure en sections et leçons, puis envoie le brouillon normalisé dans ClassroomIO.", - "review_title": "Revue à publier", - "review_description": "L'utilisateur demande des modifications, l'agent met à jour le brouillon et la publication n'a lieu qu'après approbation explicite." - }, "setup": { "title": "Configuration du client", "description": "Utilisez une clé à l’échelle de l’organisation par intégration. Si une clé est alternée ou révoquée, le client cesse de fonctionner jusqu'à ce que le nouveau secret soit configuré.", - "claude_code_description": "Enregistre `@cio/mcp` auprès de Claude Code à l'aide de la clé générée à l'échelle de l'organisation.", + "claude_code_description": "Enregistre `@classroomio/mcp` auprès de Claude Code à l'aide de la clé générée à l'échelle de l'organisation.", "codex_description": "Ajoute le serveur ClassroomIO MCP au Codex avec la même clé de portée organisationnelle.", "cursor_description": "Collez-le dans `~/.cursor/mcp.json` ou dans la configuration MCP de votre espace de travail." }, "keys": { "title": "Clés MCP", - "description": "Ces clés authentifient l'organisation actuelle lorsqu'un client MCP appelle ClassroomIO via `@cio/mcp`.", + "description": "Ces clés authentifient l'organisation actuelle lorsqu'un client MCP appelle ClassroomIO via `@classroomio/mcp`.", "generate": "Générer une clé MCP", - "scope_badge": "Brouillon créer/lire/mettre à jour/publier", "secret_once": "Ce secret est montré une fois. Copiez-le dans votre client MCP maintenant." } }, @@ -2530,10 +2515,6 @@ "permissions": { "disabled_title": "Accès en lecture seule", "disabled_description": "Seuls les administrateurs de l'organisation peuvent créer, alterner ou révoquer des clés d'automatisation. Les enseignants peuvent consulter les instructions de configuration ici." - }, - "overview": { - "title": "Accès à l'automatisation", - "description": "Créez des clés d'automatisation à l'échelle de l'organisation, connectez les clients pris en charge et conservez tous les accès aux machines liés à cette organisation." } } } diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index b70d9d8d..f35c682c 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -2474,32 +2474,17 @@ "subtitle": "एक ही ऑर्ग-स्कोप्ड कुंजी सिस्टम पर जैपियर और सार्वजनिक एपीआई के साथ अब एमसीपी क्लाइंट को कनेक्ट करें।", "coming_soon": "जल्द आ रहा है", "mcp": { - "architecture": { - "title": "वास्तुकला", - "description": "एमसीपी क्लाइंट और क्लासरूमआईओ एपीआई अलग-अलग ट्रस्ट सीमाएँ हैं। ऑर्ग-स्कोप्ड ऑटोमेशन कुंजी क्लाइंट को प्रमाणित करती है, और एपीआई सत्यापन और दृढ़ता के लिए सत्य का स्रोत बना रहता है।" - }, - "flows": { - "title": "विशिष्ट प्रवाह", - "description": "एजेंट के पास तर्क और संरचना होती है। क्लासरूमआईओ केवल सामान्यीकृत पाठ्यक्रम पेलोड स्वीकार करता है, ड्राफ्ट सहेजता है, और अनुरोध पर प्रकाशित करता है।", - "prompt_title": "मसौदा तैयार करने के लिए संकेत दें", - "prompt_description": "उपयोगकर्ता एक पाठ्यक्रम के लिए पूछता है, एजेंट इसकी संरचना करता है, फिर `create_course_draft` किसी भी प्रकाशन चरण से पहले इसे ClassroomIO में सहेजता है।", - "pdf_title": "ड्राफ्ट करने के लिए पीडीएफ", - "pdf_description": "एजेंट पीडीएफ को बाहरी रूप से पढ़ता है, इसे अनुभागों और पाठों में पुनर्गठित करता है, फिर सामान्यीकृत ड्राफ्ट को क्लासरूमआईओ में भेजता है।", - "review_title": "प्रकाशित करने हेतु समीक्षा करें", - "review_description": "उपयोगकर्ता परिवर्तन मांगता है, एजेंट ड्राफ्ट को अपडेट करता है, और प्रकाशन केवल स्पष्ट अनुमोदन के बाद ही होता है।" - }, "setup": { "title": "क्लाइंट सेटअप", "description": "प्रति एकीकरण एक ऑर्ग-स्कोप्ड कुंजी का उपयोग करें। यदि कोई कुंजी घुमाई जाती है या निरस्त की जाती है, तो क्लाइंट नया रहस्य कॉन्फ़िगर होने तक काम करना बंद कर देता है।", - "claude_code_description": "उत्पन्न ऑर्ग-स्कोप्ड कुंजी का उपयोग करके क्लाउड कोड के साथ `@cio/mcp` को पंजीकृत करता है।", + "claude_code_description": "उत्पन्न ऑर्ग-स्कोप्ड कुंजी का उपयोग करके क्लाउड कोड के साथ `@classroomio/mcp` को पंजीकृत करता है।", "codex_description": "समान ऑर्ग-स्कोप्ड कुंजी के साथ क्लासरूमआईओ एमसीपी सर्वर को कोडेक्स में जोड़ता है।", "cursor_description": "इसे `~/.cursor/mcp.json` या अपने कार्यक्षेत्र MCP कॉन्फ़िगरेशन में पेस्ट करें।" }, "keys": { "title": "एमसीपी कुंजी", - "description": "जब कोई MCP क्लाइंट `@cio/mcp` के माध्यम से ClassroomIO को कॉल करता है तो ये कुंजियाँ वर्तमान संगठन को प्रमाणित करती हैं।", + "description": "जब कोई MCP क्लाइंट `@classroomio/mcp` के माध्यम से ClassroomIO को कॉल करता है तो ये कुंजियाँ वर्तमान संगठन को प्रमाणित करती हैं।", "generate": "MCP कुंजी जनरेट करें", - "scope_badge": "ड्राफ्ट बनाएं/पढ़ें/अद्यतन करें/प्रकाशित करें", "secret_once": "यह रहस्य एक बार दिखाया गया है। अब इसे अपने MCP क्लाइंट में कॉपी करें।" } }, @@ -2530,10 +2515,6 @@ "permissions": { "disabled_title": "केवल पढ़ने योग्य पहुंच", "disabled_description": "केवल संगठन व्यवस्थापक ही स्वचालन कुंजियाँ बना सकते हैं, घुमा सकते हैं या निरस्त कर सकते हैं। शिक्षक यहां सेटअप निर्देशों की समीक्षा कर सकते हैं।" - }, - "overview": { - "title": "स्वचालन पहुंच", - "description": "ऑर्ग-स्कोप्ड ऑटोमेशन कुंजियाँ बनाएँ, समर्थित क्लाइंट्स को कनेक्ट करें, और सभी मशीन एक्सेस को इस संगठन से जोड़े रखें।" } } } diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 5305051f..2bd8c5c8 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -2474,32 +2474,17 @@ "subtitle": "Połącz teraz klientów MCP, korzystając z Zapiera i publicznego interfejsu API w tym samym systemie kluczy o zasięgu organizacji.", "coming_soon": "Już wkrótce", "mcp": { - "architecture": { - "title": "Architektura", - "description": "Klient MCP i interfejs API ClassroomIO stanowią oddzielne granice zaufania. Klucz automatyzacji o zasięgu organizacji uwierzytelnia klienta, a interfejs API pozostaje źródłem prawdy w celu sprawdzenia poprawności i trwałości." - }, - "flows": { - "title": "Typowe przepływy", - "description": "Agent jest właścicielem rozumowania i struktury. ClassroomIO akceptuje tylko znormalizowane ładunki kursów, zapisuje wersje robocze i publikuje na żądanie.", - "prompt_title": "Monituj o wersję roboczą", - "prompt_description": "Użytkownik prosi o kurs, agent tworzy go, a następnie „create_course_draft” zapisuje go w ClassroomIO przed jakimkolwiek etapem publikacji.", - "pdf_title": "PDF do wersji roboczej", - "pdf_description": "Agent czyta plik PDF zewnętrznie, dzieli go na sekcje i lekcje, a następnie wysyła znormalizowaną wersję roboczą do ClassroomIO.", - "review_title": "Recenzja do opublikowania", - "review_description": "Użytkownik prosi o zmiany, agent aktualizuje wersję roboczą, a publikacja następuje dopiero po wyraźnej zgodzie." - }, "setup": { "title": "Konfiguracja klienta", "description": "Użyj jednego klucza o zasięgu organizacji na integrację. Jeśli klucz zostanie obrócony lub unieważniony, klient przestaje działać do czasu skonfigurowania nowego klucza tajnego.", - "claude_code_description": "Rejestruje `@cio/mcp` w Claude Code przy użyciu wygenerowanego klucza o zasięgu organizacji.", + "claude_code_description": "Rejestruje `@classroomio/mcp` w Claude Code przy użyciu wygenerowanego klucza o zasięgu organizacji.", "codex_description": "Dodaje serwer ClassroomIO MCP do Codexu z tym samym kluczem o zasięgu organizacji.", "cursor_description": "Wklej to do `~/.cursor/mcp.json` lub konfiguracji MCP w obszarze roboczym." }, "keys": { "title": "Klucze MCP", - "description": "Te klucze uwierzytelniają bieżącą organizację, gdy klient MCP wywołuje ClassroomIO poprzez `@cio/mcp`.", + "description": "Te klucze uwierzytelniają bieżącą organizację, gdy klient MCP wywołuje ClassroomIO poprzez `@classroomio/mcp`.", "generate": "Wygeneruj klucz MCP", - "scope_badge": "Utwórz/odczytaj/aktualizuj/publikuj wersję roboczą", "secret_once": "Sekret ten zostaje pokazany raz. Skopiuj go teraz do swojego klienta MCP." } }, @@ -2530,10 +2515,6 @@ "permissions": { "disabled_title": "Dostęp tylko do odczytu", "disabled_description": "Tylko administratorzy organizacji mogą tworzyć, obracać i unieważniać klucze automatyzacji. Nauczyciele mogą zapoznać się z instrukcjami konfiguracji tutaj." - }, - "overview": { - "title": "Dostęp do automatyki", - "description": "Twórz klucze automatyzacji na poziomie organizacji, łącz obsługiwanych klientów i powiązuj cały dostęp do maszyn z tą organizacją." } } } diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index 7eae9e58..2e93ee7d 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -2474,32 +2474,17 @@ "subtitle": "Conecte clientes MCP agora, com o Zapier e a API pública seguindo o mesmo sistema de chaves com escopo organizacional.", "coming_soon": "Em breve", "mcp": { - "architecture": { - "title": "Arquitetura", - "description": "O cliente MCP e a API ClassroomIO são limites de confiança separados. A chave de automação no escopo organizacional autentica o cliente, e a API continua sendo a fonte da verdade para validação e persistência." - }, - "flows": { - "title": "Fluxos típicos", - "description": "O agente possui raciocínio e estrutura. ClassroomIO aceita apenas cargas de curso normalizadas, salva rascunhos e publica mediante solicitação.", - "prompt_title": "Solicitar rascunho", - "prompt_description": "O usuário solicita um curso, o agente o estrutura e, em seguida, `create_course_draft` o salva no ClassroomIO antes de qualquer etapa de publicação.", - "pdf_title": "PDF para rascunho", - "pdf_description": "O agente lê o PDF externamente, reestrutura-o em seções e lições e, em seguida, envia o rascunho normalizado para o ClassroomIO.", - "review_title": "Revise para publicar", - "review_description": "O usuário solicita alterações, o agente atualiza o rascunho e a publicação só acontece após aprovação explícita." - }, "setup": { "title": "Configuração do cliente", "description": "Use uma chave com escopo organizacional por integração. Se uma chave for rotacionada ou revogada, o cliente para de funcionar até que o novo segredo seja configurado.", - "claude_code_description": "Registra `@cio/mcp` com Claude Code usando a chave gerada com escopo organizacional.", + "claude_code_description": "Registra `@classroomio/mcp` com Claude Code usando a chave gerada com escopo organizacional.", "codex_description": "Adiciona o servidor ClassroomIO MCP ao Codex com a mesma chave com escopo organizacional.", "cursor_description": "Cole isso em `~/.cursor/mcp.json` ou na configuração do MCP do seu espaço de trabalho." }, "keys": { "title": "Chaves MCP", - "description": "Essas chaves autenticam a organização atual quando um cliente MCP chama ClassroomIO por meio de `@cio/mcp`.", + "description": "Essas chaves autenticam a organização atual quando um cliente MCP chama ClassroomIO por meio de `@classroomio/mcp`.", "generate": "Gerar chave MCP", - "scope_badge": "Rascunho criar/ler/atualizar/publicar", "secret_once": "Este segredo é mostrado uma vez. Copie-o para o seu cliente MCP agora." } }, @@ -2530,10 +2515,6 @@ "permissions": { "disabled_title": "Acesso somente leitura", "disabled_description": "Somente administradores da organização podem criar, alternar ou revogar chaves de automação. Os professores podem revisar as instruções de configuração aqui." - }, - "overview": { - "title": "Acesso de automação", - "description": "Crie chaves de automação no escopo da organização, conecte clientes compatíveis e mantenha todo o acesso às máquinas vinculado a esta organização." } } } diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 7c9872cb..d9ba2e65 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -2474,32 +2474,17 @@ "subtitle": "Подключите клиентов MCP прямо сейчас, используя Zapier и общедоступный API в одной и той же системе ключей на уровне организации.", "coming_soon": "Скоро", "mcp": { - "architecture": { - "title": "Архитектура", - "description": "Клиент MCP и API ClassroomIO представляют собой отдельные границы доверия. Ключ автоматизации на уровне организации аутентифицирует клиента, а API остается источником достоверных данных для проверки и сохранения." - }, - "flows": { - "title": "Типичные потоки", - "description": "Агент владеет рассуждениями и структурой. ClassroomIO принимает только нормализованные полезные нагрузки курса, сохраняет черновики и публикует по запросу.", - "prompt_title": "Подскажите черновик", - "prompt_description": "Пользователь запрашивает курс, агент структурирует его, затем `create_course_draft` сохраняет его в ClassroomIO перед любым шагом публикации.", - "pdf_title": "PDF для черновика", - "pdf_description": "Агент читает PDF-файл извне, реструктурирует его на разделы и уроки, а затем отправляет нормализованный черновик в ClassroomIO.", - "review_title": "Отзыв для публикации", - "review_description": "Пользователь запрашивает изменения, агент обновляет черновик, а публикация происходит только после явного одобрения." - }, "setup": { "title": "Настройка клиента", "description": "Используйте один ключ на уровне организации для каждой интеграции. Если ключ заменен или отозван, клиент перестает работать до тех пор, пока не будет настроен новый секрет.", - "claude_code_description": "Регистрирует `@cio/mcp` в Claude Code, используя сгенерированный ключ на уровне организации.", + "claude_code_description": "Регистрирует `@classroomio/mcp` в Claude Code, используя сгенерированный ключ на уровне организации.", "codex_description": "Добавляет сервер ClassroomIO MCP в Кодекс с тем же ключом на уровне организации.", "cursor_description": "Вставьте это в `~/.cursor/mcp.json` или в конфигурацию MCP вашей рабочей области." }, "keys": { "title": "Ключи MCP", - "description": "Эти ключи аутентифицируют текущую организацию, когда клиент MCP вызывает ClassroomIO через @cio/mcp.", + "description": "Эти ключи аутентифицируют текущую организацию, когда клиент MCP вызывает ClassroomIO через @classroomio/mcp.", "generate": "Создать ключ MCP", - "scope_badge": "Черновик создать/прочитать/обновить/опубликовать", "secret_once": "Этот секрет раскрывается один раз. Скопируйте его в свой клиент MCP сейчас." } }, @@ -2530,10 +2515,6 @@ "permissions": { "disabled_title": "Доступ только для чтения", "disabled_description": "Только администраторы организации могут создавать, менять или отзывать ключи автоматизации. Учителя могут ознакомиться с инструкциями по настройке здесь." - }, - "overview": { - "title": "Автоматизация доступа", - "description": "Создавайте ключи автоматизации на уровне организации, подключайте поддерживаемых клиентов и сохраняйте привязку всего доступа к машинам к этой организации." } } } diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 8ddfdfed..2324e24c 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -2474,32 +2474,17 @@ "subtitle": "Kết nối các máy khách MCP ngay bây giờ, với Zapier và API công khai trên cùng một hệ thống khóa trong phạm vi tổ chức.", "coming_soon": "Sắp có", "mcp": { - "architecture": { - "title": "Kiến trúc", - "description": "Ứng dụng khách MCP và API ClassroomIO là các ranh giới tin cậy riêng biệt. Khóa tự động hóa trong phạm vi tổ chức sẽ xác thực ứng dụng khách và API vẫn là nguồn thông tin chính xác để xác thực và duy trì." - }, - "flows": { - "title": "Dòng chảy điển hình", - "description": "Tác nhân sở hữu lý luận và cấu trúc. ClassroomIO chỉ chấp nhận tải trọng khóa học được chuẩn hóa, lưu bản nháp và xuất bản theo yêu cầu.", - "prompt_title": "Nhắc soạn thảo", - "prompt_description": "Người dùng yêu cầu một khóa học, nhân viên sẽ cấu trúc khóa học đó, sau đó `create_course_draft` lưu nó vào ClassroomIO trước bất kỳ bước xuất bản nào.", - "pdf_title": "PDF thành bản nháp", - "pdf_description": "Nhân viên đọc bản PDF bên ngoài, cấu trúc lại thành các phần và bài học, sau đó gửi bản nháp đã chuẩn hóa vào ClassroomIO.", - "review_title": "Xem xét để xuất bản", - "review_description": "Người dùng yêu cầu thay đổi, người đại diện cập nhật bản nháp và việc xuất bản chỉ diễn ra sau khi được phê duyệt rõ ràng." - }, "setup": { "title": "Thiết lập máy khách", "description": "Sử dụng một khóa trong phạm vi tổ chức cho mỗi lần tích hợp. Nếu một khóa bị xoay hoặc bị thu hồi, máy khách sẽ ngừng hoạt động cho đến khi bí mật mới được định cấu hình.", - "claude_code_description": "Đăng ký `@cio/mcp` với Mã Claude bằng cách sử dụng khóa trong phạm vi tổ chức được tạo.", + "claude_code_description": "Đăng ký `@classroomio/mcp` với Mã Claude bằng cách sử dụng khóa trong phạm vi tổ chức được tạo.", "codex_description": "Thêm máy chủ ClassroomIO MCP vào Codex bằng cùng một khóa trong phạm vi tổ chức.", "cursor_description": "Dán cái này vào `~/.cursor/mcp.json` hoặc cấu hình MCP không gian làm việc của bạn." }, "keys": { "title": "Khóa MCP", - "description": "Các khóa này xác thực tổ chức hiện tại khi máy khách MCP gọi ClassroomIO thông qua `@cio/mcp`.", + "description": "Các khóa này xác thực tổ chức hiện tại khi máy khách MCP gọi ClassroomIO thông qua `@classroomio/mcp`.", "generate": "Tạo khóa MCP", - "scope_badge": "Bản nháp tạo/đọc/cập nhật/xuất bản", "secret_once": "Bí mật này được hiển thị một lần. Sao chép nó vào ứng dụng khách MCP của bạn ngay bây giờ." } }, @@ -2530,10 +2515,6 @@ "permissions": { "disabled_title": "Quyền truy cập chỉ đọc", "disabled_description": "Chỉ quản trị viên tổ chức mới có thể tạo, xoay hoặc thu hồi khóa tự động hóa. Thầy cô có thể xem lại hướng dẫn thiết lập tại đây." - }, - "overview": { - "title": "Truy cập tự động hóa", - "description": "Tạo khóa tự động hóa trong phạm vi tổ chức, kết nối các máy khách được hỗ trợ và giữ tất cả quyền truy cập của máy được gắn chặt với tổ chức này." } } } diff --git a/apps/dashboard/src/routes/(auth)/login/+page.svelte b/apps/dashboard/src/routes/(auth)/login/+page.svelte index 1015d1ed..71f9d18b 100644 --- a/apps/dashboard/src/routes/(auth)/login/+page.svelte +++ b/apps/dashboard/src/routes/(auth)/login/+page.svelte @@ -17,6 +17,7 @@ import ShieldIcon from '@lucide/svelte/icons/shield'; const emailFromUrl = page.url.searchParams.get('email') ?? ''; + const isEmailPrefilled = !!emailFromUrl; let fields = $state(Object.assign({}, LOGIN_FIELDS, emailFromUrl ? { email: emailFromUrl } : {})); @@ -192,7 +193,8 @@ bind:value={fields.email} oninput={handleEmailChange} placeholder="you@domain.com" - disabled={loading} + disabled={loading || isEmailPrefilled} + readonly={isEmailPrefilled} autofocus aria-invalid={errors.email ? 'true' : undefined} autocomplete="username" diff --git a/apps/dashboard/src/routes/(auth)/signup/+page.svelte b/apps/dashboard/src/routes/(auth)/signup/+page.svelte index 7d0099e9..a758bdd8 100644 --- a/apps/dashboard/src/routes/(auth)/signup/+page.svelte +++ b/apps/dashboard/src/routes/(auth)/signup/+page.svelte @@ -19,6 +19,7 @@ let { data } = $props(); const emailFromUrl = page.url.searchParams.get('email') ?? ''; + const isEmailPrefilled = !!emailFromUrl; let fields = $state(Object.assign({}, SIGNUP_FIELDS, emailFromUrl ? { email: emailFromUrl } : {})); let loading = $state(false); let errors: { @@ -160,7 +161,8 @@ type="email" bind:value={fields.email} placeholder="you@domain.com" - disabled={loading} + disabled={loading || isEmailPrefilled} + readonly={isEmailPrefilled} autofocus aria-invalid={errors.email ? 'true' : undefined} autocomplete="email" diff --git a/apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts b/apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts index 17578418..e54e1ba7 100644 --- a/apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts +++ b/apps/dashboard/src/routes/course/[slug]/enroll/+page.server.ts @@ -45,6 +45,8 @@ export const load = async ({ params, url }) => { course, currentOrg, invite: data.invite, + inviteEmail: data.inviteContext?.recipientEmail ?? null, + inviteEmailExists: data.inviteContext?.recipientExists ?? false, requiresPaymentOrInvite: false, token: inviteToken }; diff --git a/apps/dashboard/src/routes/course/[slug]/enroll/+page.svelte b/apps/dashboard/src/routes/course/[slug]/enroll/+page.svelte index 0088856c..6937977a 100644 --- a/apps/dashboard/src/routes/course/[slug]/enroll/+page.svelte +++ b/apps/dashboard/src/routes/course/[slug]/enroll/+page.svelte @@ -64,7 +64,12 @@ const redirectUrl = `${redirectPath}${redirectSearch}`; if (!$profile.id || !$profile.email) { - goto(resolve(`/signup?redirect=${encodeURIComponent(redirectUrl)}`, {})); + const inviteEmail = data.inviteEmail ?? ''; + const target = data.inviteEmailExists ? '/login' : '/signup'; + const params = new URLSearchParams({ redirect: redirectUrl }); + if (inviteEmail) params.set('email', inviteEmail); + + goto(resolve(`${target}?${params.toString()}`, {})); loading = false; return; } diff --git a/apps/dashboard/src/routes/org/[slug]/automation/+page.svelte b/apps/dashboard/src/routes/org/[slug]/automation/+page.svelte new file mode 100644 index 00000000..6a40d19f --- /dev/null +++ b/apps/dashboard/src/routes/org/[slug]/automation/+page.svelte @@ -0,0 +1,22 @@ + + + + {t.get('automation.title')} - ClassroomIO + + + + + {$t('automation.title')} + {$t('automation.subtitle')} + + + + + {#snippet child()} + + {/snippet} + diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index a0def093..d1b0cfcb 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -5,6 +5,7 @@ node_modules .vercel .output .nitro +.wrangler /build/ /api/ /server/build @@ -14,6 +15,7 @@ node_modules /blob-report/ /playwright/.cache/ .tanstack +.dev.vars src/routeTree.gen.ts -.source \ No newline at end of file +.source diff --git a/apps/docs/nitro.config.ts b/apps/docs/nitro.config.ts new file mode 100644 index 00000000..e0271625 --- /dev/null +++ b/apps/docs/nitro.config.ts @@ -0,0 +1,10 @@ +import { defineNitroConfig } from 'nitro/config'; + +export default defineNitroConfig({ + compatibilityDate: '2026-03-13', + preset: 'cloudflare_module', + cloudflare: { + deployConfig: true, + nodeCompat: true + } +}); diff --git a/apps/docs/package.json b/apps/docs/package.json index 331a7cd3..182af8f7 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -4,7 +4,8 @@ "scripts": { "dev": "vite dev", "build": "vite build", - "start": "node .output/server/index.mjs", + "preview": "wrangler dev --port 4174", + "start": "wrangler dev --port 3001", "types:check": "fumadocs-mdx && tsc --noEmit", "postinstall": "fumadocs-mdx" }, @@ -32,6 +33,7 @@ "nitro": "3.0.1-alpha.1", "tailwindcss": "^4.1.16", "typescript": "^5.9.3", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "wrangler": "^4.21.2" } } diff --git a/apps/website/package.json b/apps/website/package.json index 85ab4775..0f28405d 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -5,8 +5,8 @@ "scripts": { "dev": "vite dev --port 5174", "build": "vite build", - "preview": "vite preview", - "start": "node build", + "preview": "wrangler dev --port 4173", + "start": "wrangler dev --port 3000", "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", "format": "prettier --write .", @@ -14,8 +14,7 @@ }, "devDependencies": { "@cio/tsconfig": "workspace:*", - "@sveltejs/adapter-node": "^2.0.0", - "@sveltejs/adapter-vercel": "^4.0.0", + "@sveltejs/adapter-cloudflare": "^7.0.0", "@sveltejs/kit": "^2.5.27", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@tailwindcss/typography": "^0.5.9", @@ -25,7 +24,8 @@ "svelte": "^5.0.0", "tailwindcss": "^4.1.14", "typescript": "^5.5.0", - "vite": "^5.4.4" + "vite": "^5.4.4", + "wrangler": "^4.21.2" }, "type": "module", "engines": { diff --git a/apps/website/src/blog/students-analytics.md b/apps/website/src/blog/students-analytics.md index a53bdfd1..a99502b2 100644 --- a/apps/website/src/blog/students-analytics.md +++ b/apps/website/src/blog/students-analytics.md @@ -29,8 +29,8 @@ preload="auto" autoPictureInPicture class="rounded-md" src="https://assets.cdn.clsrio.com/blog/organisation-student-analytics.mp4" -> - + +> ## Course Student Analytics @@ -52,8 +52,8 @@ autoPictureInPicture class="rounded-md" src="https://assets.cdn.clsrio.com/blog/course-students-analytics.mp4" controls -> - + +> ## What's Next? diff --git a/apps/website/svelte.config.js b/apps/website/svelte.config.js index 0a9039c3..37c74b39 100644 --- a/apps/website/svelte.config.js +++ b/apps/website/svelte.config.js @@ -1,7 +1,6 @@ import { escapeSvelte, mdsvex } from 'mdsvex'; -import adapterNode from '@sveltejs/adapter-node'; -import adapterVercel from '@sveltejs/adapter-vercel'; +import adapter from '@sveltejs/adapter-cloudflare'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import path from 'path'; @@ -11,8 +10,6 @@ import remarkUnwrapImages from 'remark-unwrap-images'; import shiki from 'shiki'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; -const useNodeAdapter = process.env.VERCEL !== '1' && process.env.VERCEL !== 'true'; - /** @type {import('@sveltejs/kit').Config}*/ /** @type {import('mdsvex').MdsvexOptions} */ @@ -34,7 +31,7 @@ const mdsvexOptions = { const config = { kit: { - adapter: useNodeAdapter ? adapterNode() : adapterVercel(), + adapter: adapter(), alias: { $lib: path.resolve('./src/lib'), '$src/tools': path.resolve('./node_modules/@cio/ui/src/tools/index.ts'), diff --git a/apps/website/wrangler.jsonc b/apps/website/wrangler.jsonc new file mode 100644 index 00000000..496459c6 --- /dev/null +++ b/apps/website/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "classroomio-website", + "main": ".svelte-kit/cloudflare/_worker.js", + "compatibility_date": "2026-03-13", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "binding": "ASSETS", + "directory": ".svelte-kit/cloudflare" + } +} diff --git a/packages/db/src/queries/auth/profile.ts b/packages/db/src/queries/auth/profile.ts index 2d405405..651d1d3f 100644 --- a/packages/db/src/queries/auth/profile.ts +++ b/packages/db/src/queries/auth/profile.ts @@ -26,8 +26,5 @@ export const markUserAndProfileEmailVerified = async (userId: string, dbClient: const verifiedAt = new Date().toISOString(); await dbClient.update(schema.user).set({ emailVerified: true }).where(eq(schema.user.id, userId)); - await dbClient - .update(schema.profile) - .set({ isEmailVerified: true, verifiedAt }) - .where(eq(schema.profile.id, userId)); + await dbClient.update(schema.profile).set({ isEmailVerified: true, verifiedAt }).where(eq(schema.profile.id, userId)); }; diff --git a/packages/db/src/queries/course-import/course-import.ts b/packages/db/src/queries/course-import/course-import.ts new file mode 100644 index 00000000..3e5624a9 --- /dev/null +++ b/packages/db/src/queries/course-import/course-import.ts @@ -0,0 +1,81 @@ +import * as schema from '@db/schema'; + +import { and, eq } from 'drizzle-orm'; + +import { db } from '@db/drizzle'; +import type { TCourseImportDraft, TNewCourseImportDraft } from '@db/types'; + +export async function createCourseImportDraft(data: TNewCourseImportDraft): Promise { + try { + const [draft] = await db.insert(schema.courseImportDraft).values(data).returning(); + + if (!draft) { + throw new Error('Failed to create course import draft'); + } + + return draft; + } catch (error) { + console.error('createCourseImportDraft error:', error); + throw new Error('Failed to create course import draft'); + } +} + +export async function getCourseImportDraftById(orgId: string, draftId: string): Promise { + try { + const [draft] = await db + .select() + .from(schema.courseImportDraft) + .where(and(eq(schema.courseImportDraft.organizationId, orgId), eq(schema.courseImportDraft.id, draftId))) + .limit(1); + + return draft || null; + } catch (error) { + console.error('getCourseImportDraftById error:', error); + throw new Error('Failed to fetch course import draft'); + } +} + +export async function getCourseImportDraftByIdempotencyKey( + orgId: string, + idempotencyKey: string +): Promise { + try { + const [draft] = await db + .select() + .from(schema.courseImportDraft) + .where( + and( + eq(schema.courseImportDraft.organizationId, orgId), + eq(schema.courseImportDraft.idempotencyKey, idempotencyKey) + ) + ) + .limit(1); + + return draft || null; + } catch (error) { + console.error('getCourseImportDraftByIdempotencyKey error:', error); + throw new Error('Failed to fetch course import draft by idempotency key'); + } +} + +export async function updateCourseImportDraft( + orgId: string, + draftId: string, + data: Partial +): Promise { + try { + const [draft] = await db + .update(schema.courseImportDraft) + .set({ + ...data, + updatedAt: new Date().toISOString() + }) + .where(and(eq(schema.courseImportDraft.organizationId, orgId), eq(schema.courseImportDraft.id, draftId))) + .returning(); + + return draft || null; + } catch (error) { + console.error('updateCourseImportDraft error:', error); + throw new Error('Failed to update course import draft'); + } +} diff --git a/packages/db/src/queries/course-import/index.ts b/packages/db/src/queries/course-import/index.ts new file mode 100644 index 00000000..985dfec0 --- /dev/null +++ b/packages/db/src/queries/course-import/index.ts @@ -0,0 +1 @@ +export * from './course-import'; diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts index 4f2a1c80..de9d5f7d 100644 --- a/packages/db/src/queries/index.ts +++ b/packages/db/src/queries/index.ts @@ -11,3 +11,4 @@ export * from './mark'; export * from './newsfeed'; export * from './submission'; export * from './tag'; +export * from './course-import'; diff --git a/packages/db/src/queries/organization/automation-key.ts b/packages/db/src/queries/organization/automation-key.ts new file mode 100644 index 00000000..ec37eedf --- /dev/null +++ b/packages/db/src/queries/organization/automation-key.ts @@ -0,0 +1,98 @@ +import * as schema from '@db/schema'; + +import { and, desc, eq, isNull } from 'drizzle-orm'; + +import { db } from '@db/drizzle'; +import type { TNewOrganizationApiKey, TOrganizationApiKey, TOrganizationApiKeyType } from '@db/types'; + +export const createOrganizationApiKey = async (data: TNewOrganizationApiKey): Promise => { + try { + const [row] = await db.insert(schema.organizationApiKey).values(data).returning(); + + if (!row) { + throw new Error('Failed to create organization API key'); + } + + return row; + } catch (error) { + console.error('createOrganizationApiKey error:', error); + throw new Error('Failed to create organization API key'); + } +}; + +export const listOrganizationApiKeys = async ( + organizationId: string, + type?: TOrganizationApiKeyType +): Promise => { + try { + const conditions = [eq(schema.organizationApiKey.organizationId, organizationId)]; + + if (type) { + conditions.push(eq(schema.organizationApiKey.type, type)); + } + + return db + .select() + .from(schema.organizationApiKey) + .where(and(...conditions)) + .orderBy(desc(schema.organizationApiKey.createdAt)); + } catch (error) { + console.error('listOrganizationApiKeys error:', error); + throw new Error('Failed to list organization API keys'); + } +}; + +export const getOrganizationApiKeyById = async ( + organizationId: string, + keyId: string +): Promise => { + try { + const [row] = await db + .select() + .from(schema.organizationApiKey) + .where(and(eq(schema.organizationApiKey.organizationId, organizationId), eq(schema.organizationApiKey.id, keyId))) + .limit(1); + + return row || null; + } catch (error) { + console.error('getOrganizationApiKeyById error:', error); + throw new Error('Failed to get organization API key by id'); + } +}; + +export const getActiveOrganizationApiKeyByHash = async (secretHash: string): Promise => { + try { + const [row] = await db + .select() + .from(schema.organizationApiKey) + .where(and(eq(schema.organizationApiKey.secretHash, secretHash), isNull(schema.organizationApiKey.revokedAt))) + .limit(1); + + return row || null; + } catch (error) { + console.error('getActiveOrganizationApiKeyByHash error:', error); + throw new Error('Failed to get organization API key by hash'); + } +}; + +export const updateOrganizationApiKey = async ( + organizationId: string, + keyId: string, + data: Partial +): Promise => { + try { + const [row] = await db + .update(schema.organizationApiKey) + .set({ + ...data, + updatedAt: new Date().toISOString() + }) + .where(and(eq(schema.organizationApiKey.organizationId, organizationId), eq(schema.organizationApiKey.id, keyId))) + .returning(); + + return row || null; + } catch (error) { + console.error('updateOrganizationApiKey error:', error); + throw new Error('Failed to update organization API key'); + } +}; diff --git a/packages/db/src/queries/organization/index.ts b/packages/db/src/queries/organization/index.ts index 742297b4..7ad75cc6 100644 --- a/packages/db/src/queries/organization/index.ts +++ b/packages/db/src/queries/organization/index.ts @@ -1,3 +1,4 @@ export * from './organization'; export * from './quiz'; export * from './invite'; +export * from './automation-key'; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index fa8e4e30..0dfb1941 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -25,6 +25,9 @@ import { sql } from 'drizzle-orm'; export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS']); export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']); export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']); +export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf']); +export const courseImportDraftStatus = pgEnum('COURSE_IMPORT_DRAFT_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']); +export const organizationApiKeyType = pgEnum('ORGANIZATION_API_KEY_TYPE', ['mcp', 'api', 'zapier']); export const courseInviteEventType = pgEnum('COURSE_INVITE_EVENT_TYPE', [ 'CREATED', 'REVOKED', @@ -1996,3 +1999,77 @@ export const organizationTokenAuth = pgTable( }, (table) => [index('idx_organization_token_auth_org_id').on(table.organizationId)] ); + +export const organizationApiKey = pgTable( + 'organization_api_key', + { + id: uuid('id') + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + organizationId: uuid('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + createdByProfileId: uuid('created_by_profile_id') + .notNull() + .references(() => profile.id), + type: organizationApiKeyType('type').notNull(), + label: varchar('label', { length: 120 }).notNull(), + secretPrefix: varchar('secret_prefix', { length: 32 }).notNull(), + secretHash: text('secret_hash').notNull(), + scopes: jsonb('scopes').default([]).notNull().$type(), + lastUsedAt: timestamp('last_used_at', { withTimezone: true, mode: 'string' }), + expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'string' }), + revokedAt: timestamp('revoked_at', { withTimezone: true, mode: 'string' }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .default(sql`timezone('utc'::text, now())`) + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .default(sql`timezone('utc'::text, now())`) + .notNull() + }, + (table) => [ + index('idx_organization_api_key_org_id').on(table.organizationId), + index('idx_organization_api_key_type').on(table.type), + uniqueIndex('organization_api_key_secret_hash_unique').on(table.secretHash) + ] +); + +export const courseImportDraft = pgTable( + 'course_import_draft', + { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + organizationId: uuid('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + createdByProfileId: uuid('created_by_profile_id') + .notNull() + .references(() => profile.id), + sourceType: courseImportSourceType('source_type').notNull(), + status: courseImportDraftStatus('status').default('DRAFT').notNull(), + title: varchar().notNull(), + locale: locale().default('en').notNull(), + idempotencyKey: varchar('idempotency_key'), + summary: jsonb().default({}).notNull().$type>(), + draft: jsonb().notNull().$type>(), + warnings: jsonb().default([]).notNull().$type>>(), + sourceArtifacts: jsonb().default([]).notNull().$type>>(), + publishedCourseId: uuid('published_course_id').references(() => course.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .default(sql`timezone('utc'::text, now())`) + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .default(sql`timezone('utc'::text, now())`) + .notNull() + }, + (table) => [ + index('idx_course_import_draft_org_id').on(table.organizationId), + index('idx_course_import_draft_status').on(table.status), + uniqueIndex('course_import_draft_org_idempotency_key') + .on(table.organizationId, table.idempotencyKey) + .where(sql`${table.idempotencyKey} IS NOT NULL`) + ] +); diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 064ff56a..7052328c 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -169,6 +169,12 @@ export type TNewOrganizationSsoConfig = typeof schema.organizationSsoConfig.$inf export type TOrganizationTokenAuth = typeof schema.organizationTokenAuth.$inferSelect; export type TNewOrganizationTokenAuth = typeof schema.organizationTokenAuth.$inferInsert; +export type TOrganizationApiKey = typeof schema.organizationApiKey.$inferSelect; +export type TNewOrganizationApiKey = typeof schema.organizationApiKey.$inferInsert; + +export type TCourseImportDraft = typeof schema.courseImportDraft.$inferSelect; +export type TNewCourseImportDraft = typeof schema.courseImportDraft.$inferInsert; + export type TCourseType = (typeof schema.courseType.enumValues)[number]; export type TLocale = (typeof schema.locale.enumValues)[number]; @@ -180,3 +186,9 @@ export type TCourseInviteEventType = (typeof schema.courseInviteEventType.enumVa export type TOrganizationInviteEventType = (typeof schema.organizationInviteEventType.enumValues)[number]; export type TSsoProvider = (typeof schema.organizationSsoProviderType.enumValues)[number]; + +export type TOrganizationApiKeyType = (typeof schema.organizationApiKeyType.enumValues)[number]; + +export type TCourseImportSourceType = (typeof schema.courseImportSourceType.enumValues)[number]; + +export type TCourseImportDraftStatus = (typeof schema.courseImportDraftStatus.enumValues)[number]; diff --git a/packages/ui/src/base/field/field-separator.svelte b/packages/ui/src/base/field/field-separator.svelte index 8c4d08cf..e7b8e7a8 100644 --- a/packages/ui/src/base/field/field-separator.svelte +++ b/packages/ui/src/base/field/field-separator.svelte @@ -20,7 +20,7 @@ bind:this={ref} data-slot="field-separator" data-content={hasContent} - class={cn('ui:relative ui:-my-2 ui:h-5 ui:text-sm ui:group-data-[variant=outline]/field-group:-mb-2', className)} + class={cn('ui:relative ui:my-2 ui:h-5 ui:text-sm ui:group-data-[variant=outline]/field-group:-mb-2', className)} {...restProps} > diff --git a/packages/utils/src/functions/fileValidation.ts b/packages/utils/src/functions/fileValidation.ts index 843bd338..c93109cd 100644 --- a/packages/utils/src/functions/fileValidation.ts +++ b/packages/utils/src/functions/fileValidation.ts @@ -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; diff --git a/packages/utils/src/functions/index.ts b/packages/utils/src/functions/index.ts index 4b39c330..da9cd59d 100644 --- a/packages/utils/src/functions/index.ts +++ b/packages/utils/src/functions/index.ts @@ -1,2 +1,3 @@ -export * from './fileValidation'; export * from './array'; +export * from './fileValidation'; +export * from './slug'; diff --git a/packages/utils/src/functions/slug.ts b/packages/utils/src/functions/slug.ts new file mode 100644 index 00000000..5f3eed10 --- /dev/null +++ b/packages/utils/src/functions/slug.ts @@ -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; +} diff --git a/packages/utils/src/validation/course-import/course-import.ts b/packages/utils/src/validation/course-import/course-import.ts new file mode 100644 index 00000000..8ca8d759 --- /dev/null +++ b/packages/utils/src/validation/course-import/course-import.ts @@ -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; + +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; + +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; + +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; + +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; + +export const ZCourseImportDraftLessonLanguage = z.object({ + lessonExternalId: z.string().min(1), + locale: ZSupportedLocale, + content: z.string().min(1) +}); +export type TCourseImportDraftLessonLanguage = z.infer; + +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(); + 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(); + 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; + +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; + +export const ZCourseImportDraftGetParam = z.object({ + draftId: z.string().uuid() +}); +export type TCourseImportDraftGetParam = z.infer; + +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; + +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; diff --git a/packages/utils/src/validation/course-import/index.ts b/packages/utils/src/validation/course-import/index.ts new file mode 100644 index 00000000..985dfec0 --- /dev/null +++ b/packages/utils/src/validation/course-import/index.ts @@ -0,0 +1 @@ +export * from './course-import'; diff --git a/packages/utils/src/validation/index.ts b/packages/utils/src/validation/index.ts index 9e0ba8fd..c1ce6620 100644 --- a/packages/utils/src/validation/index.ts +++ b/packages/utils/src/validation/index.ts @@ -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'; diff --git a/packages/utils/src/validation/organization/automation-key.ts b/packages/utils/src/validation/organization/automation-key.ts new file mode 100644 index 00000000..bfa935d9 --- /dev/null +++ b/packages/utils/src/validation/organization/automation-key.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +export const ZOrganizationApiKeyType = z.enum(['mcp', 'api', 'zapier']); +export type TOrganizationApiKeyType = z.infer; + +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; + +export const ZListOrganizationApiKeysQuery = z.object({ + type: ZOrganizationApiKeyType.optional() +}); +export type TListOrganizationApiKeysQuery = z.infer; + +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; + +export const ZOrganizationApiKeyParam = z.object({ + keyId: z.string().uuid() +}); +export type TOrganizationApiKeyParam = z.infer; diff --git a/packages/utils/src/validation/organization/index.ts b/packages/utils/src/validation/organization/index.ts index 966ca502..4cebf65e 100644 --- a/packages/utils/src/validation/organization/index.ts +++ b/packages/utils/src/validation/organization/index.ts @@ -5,3 +5,4 @@ export * from './invite'; export * from './sso'; export * from './login-link'; export * from './token-auth'; +export * from './automation-key'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94a5f012..3976d5ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,13 +83,13 @@ importers: version: 16.6.1 drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) + version: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) hono: specifier: ^4.11.3 version: 4.11.3 hono-openapi: specifier: ^1.0.8 - version: 1.1.2(@hono/standard-validator@0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.3))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1))(@types/json-schema@7.0.15)(hono@4.11.3)(openapi-types@12.1.3) + version: 1.1.2(@hono/standard-validator@0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.3))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.1(zod@4.2.1))(zod@4.2.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.1(zod@4.2.1))(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1))(@types/json-schema@7.0.15)(hono@4.11.3)(openapi-types@12.1.3) hono-rate-limiter: specifier: ^0.4.2 version: 0.4.2(hono@4.11.3) @@ -150,7 +150,7 @@ importers: version: 1.6.1(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1) wrangler: specifier: 4.21.x - version: 4.21.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) + version: 4.21.2(@cloudflare/workers-types@4.20260313.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) apps/course-app: dependencies: @@ -241,7 +241,7 @@ importers: dependencies: '@better-auth/sso': specifier: 1.4.18 - version: 1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3))) + version: 1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3))) '@cio/api': specifier: workspace:* version: link:../api @@ -295,7 +295,7 @@ importers: version: 1.13.2 better-auth: specifier: ^1.4.18 - version: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) + version: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) bits-ui: specifier: ^2.14.4 version: 2.14.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0) @@ -591,7 +591,7 @@ importers: version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) nitro: specifier: 3.0.1-alpha.1 - version: 3.0.1-alpha.1(chokidar@4.0.3)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(ioredis@5.8.2)(lru-cache@11.2.4)(rollup@4.54.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.0.1-alpha.1(chokidar@4.0.3)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(ioredis@5.8.2)(lru-cache@11.2.4)(rollup@4.54.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) tailwindcss: specifier: ^4.1.16 version: 4.1.18 @@ -601,6 +601,9 @@ importers: vite-tsconfig-paths: specifier: ^5.1.4 version: 5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) + wrangler: + specifier: ^4.21.2 + version: 4.21.2(@cloudflare/workers-types@4.20260313.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) apps/website: dependencies: @@ -644,12 +647,9 @@ importers: '@cio/tsconfig': specifier: workspace:* version: link:../../packages/tsconfig - '@sveltejs/adapter-auto': - specifier: ^3.0.0 - version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1))) - '@sveltejs/adapter-vercel': - specifier: ^4.0.0 - version: 4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(encoding@0.1.13) + '@sveltejs/adapter-cloudflare': + specifier: ^7.0.0 + version: 7.2.8(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(wrangler@4.21.2(@cloudflare/workers-types@4.20260313.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@sveltejs/kit': specifier: ^2.5.27 version: 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) @@ -680,6 +680,9 @@ importers: vite: specifier: ^5.4.4 version: 5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1) + wrangler: + specifier: ^4.21.2 + version: 4.21.2(@cloudflare/workers-types@4.20260313.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) packages/course-app: dependencies: @@ -807,7 +810,7 @@ importers: dependencies: '@better-auth/sso': specifier: ^1.4.18 - version: 1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3))) + version: 1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3))) '@cio/email': specifier: workspace:* version: link:../email @@ -825,13 +828,13 @@ importers: version: 6.0.0 better-auth: specifier: ^1.4.18 - version: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) + version: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) drizzle-kit: specifier: ^0.31.8 version: 0.31.8 drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) + version: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) jose: specifier: ^6.1.3 version: 6.1.3 @@ -901,6 +904,34 @@ importers: specifier: ^5.5.2 version: 5.9.3 + packages/mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@4.3.6) + zod: + specifier: ^4.1.8 + version: 4.3.6 + devDependencies: + '@cio/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@cio/utils': + specifier: workspace:* + version: link:../utils + '@types/node': + specifier: ^24.10.1 + version: 24.10.4 + esbuild: + specifier: ^0.25.11 + version: 0.25.12 + tsx: + specifier: ^4.20.3 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/question-types: dependencies: '@cio/tsconfig': @@ -2094,6 +2125,9 @@ packages: cpu: [x64] os: [win32] + '@cloudflare/workers-types@4.20260313.1': + resolution: {integrity: sha512-jMEeX3RKfOSVqqXRKr/ulgglcTloeMzSH3FdzIfqJHtvc12/ELKd5Ldsg8ZHahKX/4eRxYdw3kbzb8jLXbq/jQ==} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -3120,6 +3154,12 @@ packages: '@hapi/topo@5.1.0': resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hono/node-server@1.19.7': resolution: {integrity: sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==} engines: {node: '>=18.14.1'} @@ -3467,6 +3507,16 @@ packages: peerDependencies: svelte: '>=3 <5' + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@1.1.0': resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} @@ -5111,6 +5161,12 @@ packages: peerDependencies: '@sveltejs/kit': ^2.0.0 + '@sveltejs/adapter-cloudflare@7.2.8': + resolution: {integrity: sha512-bIdhY/Fi4AQmqiBdQVKnafH1h9Gw+xbCvHyUu4EouC8rJOU02zwhi14k/FDhQ0mJF1iblIu3m8UNQ8GpGIvIOQ==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + wrangler: ^4.0.0 + '@sveltejs/adapter-node@2.1.2': resolution: {integrity: sha512-ZfVY5buBclWHoBT+RbkMUViJGEIZ3IfT/0Hvhlgp+qC3LRZwp+wS1Zsw5dgkB2sFDZXctbLNXJtwlkjSp1mw0g==} peerDependencies: @@ -6230,6 +6286,10 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -6287,9 +6347,20 @@ packages: vue: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + all-object-keys@2.2.0: resolution: {integrity: sha512-x8eEZ/ZhC6OjCKanec3y7DpCj/Kymife0yVkq56XDWUwZcZotzC0ejgO5/dbgRiBVMRb09dY+JP9dySCHysl9A==} engines: {node: '>=8'} @@ -6603,6 +6674,10 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -6925,6 +7000,10 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} @@ -7012,6 +7091,10 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -7032,6 +7115,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7621,6 +7708,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -7882,10 +7973,22 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventsource-parser@1.0.0: resolution: {integrity: sha512-9jgfSCa3dmEme2ES3mPByGXfgZ87VbP97tng1G2nWwWx6bV2nYxm2AWCrbQjXToSe+yYlqaZNtxffR9IeQr95g==} engines: {node: '>=14.18'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -7910,6 +8013,16 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -7936,6 +8049,9 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@5.2.5: resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} hasBin: true @@ -7989,6 +8105,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@2.1.0: resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} engines: {node: '>=4'} @@ -8036,12 +8156,20 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} framesync@6.1.2: resolution: {integrity: sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-extra@11.3.3: resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} @@ -8394,6 +8522,10 @@ packages: resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==} engines: {node: '>=16.9.0'} + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} + engines: {node: '>=16.9.0'} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -8459,6 +8591,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ignore@4.0.6: resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==} engines: {node: '>= 4'} @@ -8534,6 +8670,14 @@ packages: resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} engines: {node: '>=12.22.0'} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -8643,6 +8787,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -8953,6 +9100,12 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -9417,6 +9570,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memoize@10.2.0: resolution: {integrity: sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==} engines: {node: '>=18'} @@ -9428,6 +9585,10 @@ packages: resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} engines: {node: '>=10'} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -9548,10 +9709,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -9974,6 +10143,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -10084,6 +10257,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -10460,6 +10637,10 @@ packages: prosemirror-view@1.41.4: resolution: {integrity: sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -10491,6 +10672,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -10508,6 +10693,10 @@ packages: raf@3.4.1: resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + rangetouch@2.0.1: resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==} @@ -10515,6 +10704,10 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -10670,6 +10863,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + regexparam@3.0.0: + resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} + engines: {node: '>=8'} + regexpp@3.2.0: resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} engines: {node: '>=8'} @@ -10722,6 +10919,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} @@ -10785,6 +10986,10 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} @@ -10900,6 +11105,10 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + seroval-plugins@1.3.3: resolution: {integrity: sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==} engines: {node: '>=10'} @@ -10920,6 +11129,10 @@ packages: resolution: {integrity: sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -11831,6 +12044,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} @@ -12094,6 +12311,10 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vaul-svelte@1.0.0-next.7: resolution: {integrity: sha512-7zN7Bi3dFQixvvbUJY9uGDe7Ws/dGZeBQR2pXdXmzQiakjrxBvWo0QrmsX3HK+VH+SZOltz378cmgmCS9f9rSg==} engines: {node: '>=18', pnpm: '>=8.7.0'} @@ -12394,6 +12615,10 @@ packages: engines: {node: '>=16'} hasBin: true + worktop@0.8.0-next.18: + resolution: {integrity: sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw==} + engines: {node: '>=12'} + wrangler@4.21.2: resolution: {integrity: sha512-POC8gGIAsJIYISxVe/oWIjSNwCqfaHMcDPzo6zuGTGvqYC33UM5WI82nULse1bNpXBC0L0XpqtHysW3sDqa8DQ==} engines: {node: '>=18.0.0'} @@ -12556,6 +12781,11 @@ packages: peerDependencies: zod: ^3.25.74 || ^4.0.0 + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -13946,21 +14176,21 @@ snapshots: nanostores: 1.1.0 zod: 4.3.6 - '@better-auth/sso@1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)))': + '@better-auth/sso@1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)))': dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 - better-auth: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) + better-auth: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) fast-xml-parser: 5.2.5 jose: 6.1.3 samlify: 2.10.2 zod: 4.3.6 - '@better-auth/sso@1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)))': + '@better-auth/sso@1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)))': dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 - better-auth: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) + better-auth: 1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) fast-xml-parser: 5.2.5 jose: 6.1.3 samlify: 2.10.2 @@ -14001,6 +14231,8 @@ snapshots: '@cloudflare/workerd-windows-64@1.20250617.0': optional: true + '@cloudflare/workers-types@4.20260313.1': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -14613,6 +14845,10 @@ snapshots: dependencies: '@hapi/hoek': 9.3.0 + '@hono/node-server@1.19.11(hono@4.12.7)': + dependencies: + hono: 4.12.7 + '@hono/node-server@1.19.7(hono@4.11.3)': dependencies: hono: 4.11.3 @@ -15118,6 +15354,28 @@ snapshots: nanoid: 5.1.6 svelte: 5.46.0 + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.7) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.7 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.1.0': dependencies: '@emnapi/core': 1.7.1 @@ -16406,17 +16664,18 @@ snapshots: '@stablelib/base64@1.0.1': {} - '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1)': + '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.1(zod@4.2.1))(zod@4.2.1)': dependencies: '@standard-schema/spec': 1.1.0 '@types/json-schema': 7.0.15 quansync: 1.0.0 optionalDependencies: zod: 4.2.1 + zod-to-json-schema: 3.25.1(zod@4.2.1) - '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1)': + '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.1(zod@4.2.1))(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1)': dependencies: - '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1) + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.1(zod@4.2.1))(zod@4.2.1) '@standard-schema/spec': 1.1.0 openapi-types: 12.1.3 optionalDependencies: @@ -16772,11 +17031,6 @@ snapshots: '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.31.1)(sass@1.97.1)) import-meta-resolve: 4.2.0 - '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))': - dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) - import-meta-resolve: 4.2.0 - '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -16787,6 +17041,13 @@ snapshots: '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) import-meta-resolve: 4.2.0 + '@sveltejs/adapter-cloudflare@7.2.8(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(wrangler@4.21.2(@cloudflare/workers-types@4.20260313.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + '@cloudflare/workers-types': 4.20260313.1 + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) + worktop: 0.8.0-next.18 + wrangler: 4.21.2(@cloudflare/workers-types@4.20260313.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@sveltejs/adapter-node@2.1.2(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))': dependencies: '@rollup/plugin-commonjs': 25.0.8(rollup@4.54.0) @@ -16804,15 +17065,6 @@ snapshots: - encoding - supports-color - '@sveltejs/adapter-vercel@4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(encoding@0.1.13)': - dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) - '@vercel/nft': 0.26.5(encoding@0.1.13) - esbuild: 0.19.12 - transitivePeerDependencies: - - encoding - - supports-color - '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1))': dependencies: '@standard-schema/spec': 1.1.0 @@ -18454,6 +18706,11 @@ snapshots: abbrev@1.1.1: {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -18497,6 +18754,10 @@ snapshots: svelte: 5.46.0 vue: 3.5.26(typescript@5.9.3) + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -18504,6 +18765,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + all-object-keys@2.2.0: dependencies: try-catch: 3.0.1 @@ -18730,7 +18998,7 @@ snapshots: node-addon-api: 8.5.0 node-gyp-build: 4.8.4 - better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)): + better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) @@ -18748,7 +19016,7 @@ snapshots: '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) '@tanstack/react-start': 1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)) drizzle-kit: 0.31.8 - drizzle-orm: 0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) + drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) pg: 8.18.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -18757,7 +19025,7 @@ snapshots: vitest: 2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1) vue: 3.5.26(typescript@5.9.3) - better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)): + better-auth@1.4.18(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@24.10.4)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) @@ -18775,7 +19043,7 @@ snapshots: '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/react-start': 1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)) drizzle-kit: 0.31.8 - drizzle-orm: 0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) + drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) pg: 8.18.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -18855,6 +19123,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} bowser@2.13.1: {} @@ -19182,6 +19464,8 @@ snapshots: console-control-strings@1.1.0: {} + content-disposition@1.0.1: {} + content-type@1.0.5: {} conventional-changelog-angular@5.0.13: @@ -19307,6 +19591,8 @@ snapshots: cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} + cookie@0.6.0: {} cookie@0.7.2: {} @@ -19323,6 +19609,11 @@ snapshots: core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + create-jest@29.7.0(@types/node@20.19.27)(ts-node@10.9.2(@types/node@20.19.27)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 @@ -19586,9 +19877,9 @@ snapshots: dayjs@1.11.19: {} - db0@0.3.4(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)): + db0@0.3.4(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)): optionalDependencies: - drizzle-orm: 0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) + drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7) debug@2.6.9: dependencies: @@ -19788,8 +20079,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7): + drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7): optionalDependencies: + '@cloudflare/workers-types': 4.20260313.1 '@types/pg': 8.16.0 kysely: 0.28.9 pg: 8.18.0 @@ -19813,6 +20105,8 @@ snapshots: emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -20293,8 +20587,16 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + eventsource-parser@1.0.0: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -20333,6 +20635,44 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} extend@3.0.2: {} @@ -20359,6 +20699,8 @@ snapshots: fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} + fast-xml-parser@5.2.5: dependencies: strnum: 2.1.2 @@ -20403,6 +20745,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@2.1.0: dependencies: locate-path: 2.0.0 @@ -20452,12 +20805,16 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + fraction.js@5.3.4: {} framesync@6.1.2: dependencies: tslib: 2.4.0 + fresh@2.0.0: {} + fs-extra@11.3.3: dependencies: graceful-fs: 4.2.11 @@ -20525,7 +20882,7 @@ snapshots: unist-util-remove-position: 5.0.0 unist-util-visit: 5.0.0 vfile: 6.0.3 - zod: 4.2.1 + zod: 4.3.6 optionalDependencies: react: 19.2.3 vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) @@ -20847,10 +21204,10 @@ snapshots: highlight.js@11.11.1: {} - hono-openapi@1.1.2(@hono/standard-validator@0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.3))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1))(@types/json-schema@7.0.15)(hono@4.11.3)(openapi-types@12.1.3): + hono-openapi@1.1.2(@hono/standard-validator@0.1.5(@standard-schema/spec@1.1.0)(hono@4.11.3))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.1(zod@4.2.1))(zod@4.2.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.1(zod@4.2.1))(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1))(@types/json-schema@7.0.15)(hono@4.11.3)(openapi-types@12.1.3): dependencies: - '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1) - '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1) + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.1(zod@4.2.1))(zod@4.2.1) + '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.1(zod@4.2.1))(zod@4.2.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod-openapi@5.4.5(zod@4.2.1))(zod@4.2.1) '@types/json-schema': 7.0.15 openapi-types: 12.1.3 optionalDependencies: @@ -20863,6 +21220,8 @@ snapshots: hono@4.11.3: {} + hono@4.12.7: {} + hosted-git-info@2.8.9: {} hosted-git-info@4.1.0: @@ -20942,6 +21301,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ignore@4.0.6: {} ignore@5.3.2: {} @@ -21022,6 +21385,10 @@ snapshots: - supports-color optional: true + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -21113,6 +21480,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.8 @@ -21679,6 +22048,10 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} @@ -22219,6 +22592,8 @@ snapshots: media-typer@0.3.0: {} + media-typer@1.1.0: {} + memoize@10.2.0: dependencies: mimic-function: 5.0.1 @@ -22241,6 +22616,8 @@ snapshots: type-fest: 0.18.1 yargs-parser: 20.2.9 + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -22516,10 +22893,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@3.0.0: {} mimic-fn@2.1.0: {} @@ -22649,11 +23032,11 @@ snapshots: nf3@0.1.12: {} - nitro@3.0.1-alpha.1(chokidar@4.0.3)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(ioredis@5.8.2)(lru-cache@11.2.4)(rollup@4.54.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): + nitro@3.0.1-alpha.1(chokidar@4.0.3)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(ioredis@5.8.2)(lru-cache@11.2.4)(rollup@4.54.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 crossws: 0.4.1(srvx@0.9.8) - db0: 0.3.4(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)) + db0: 0.3.4(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)) h3: 2.0.1-rc.5(crossws@0.4.1(srvx@0.9.8)) jiti: 2.6.1 nf3: 0.1.12 @@ -22664,7 +23047,7 @@ snapshots: srvx: 0.9.8 undici: 7.16.0 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.4(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)))(ioredis@5.8.2)(lru-cache@11.2.4)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.4(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)))(ioredis@5.8.2)(lru-cache@11.2.4)(ofetch@2.0.0-alpha.3) optionalDependencies: rollup: 4.54.0 vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2) @@ -23003,6 +23386,8 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -23083,6 +23468,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-dir@4.2.0: dependencies: find-up: 4.1.0 @@ -23401,6 +23788,11 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.10.5 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} punycode.js@2.3.1: {} @@ -23421,6 +23813,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + quansync@1.0.0: {} queue-lit@1.5.2: {} @@ -23434,6 +23830,8 @@ snapshots: performance-now: 2.1.0 optional: true + range-parser@1.2.1: {} + rangetouch@2.0.1: {} raw-body@2.5.3: @@ -23443,6 +23841,13 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -23636,6 +24041,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + regexparam@3.0.0: {} + regexpp@3.2.0: {} regexpu-core@6.4.0: @@ -23739,6 +24146,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} resolve-cwd@3.0.0: @@ -23811,6 +24220,16 @@ snapshots: rou3@0.7.12: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + rrweb-cssom@0.7.1: {} rrweb-cssom@0.8.0: {} @@ -23937,6 +24356,22 @@ snapshots: semver@7.7.3: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + seroval-plugins@1.3.3(seroval@1.3.2): dependencies: seroval: 1.3.2 @@ -23949,6 +24384,15 @@ snapshots: seroval@1.4.1: {} + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: {} set-cookie-parser@2.7.2: {} @@ -24922,6 +25366,12 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typedarray@0.0.6: {} typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): @@ -25062,10 +25512,10 @@ snapshots: unsplash-js@7.0.20: {} - unstorage@2.0.0-alpha.4(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)))(ioredis@5.8.2)(lru-cache@11.2.4)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.4(chokidar@4.0.3)(db0@0.3.4(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)))(ioredis@5.8.2)(lru-cache@11.2.4)(ofetch@2.0.0-alpha.3): optionalDependencies: chokidar: 4.0.3 - db0: 0.3.4(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)) + db0: 0.3.4(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7)) ioredis: 5.8.2 lru-cache: 11.2.4 ofetch: 2.0.0-alpha.3 @@ -25139,6 +25589,8 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 + vary@1.1.2: {} + vaul-svelte@1.0.0-next.7(svelte@5.46.0): dependencies: runed: 0.23.4(svelte@5.46.0) @@ -25638,7 +26090,12 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20250617.0 '@cloudflare/workerd-windows-64': 1.20250617.0 - wrangler@4.21.2(bufferutil@4.1.0)(utf-8-validate@6.0.6): + worktop@0.8.0-next.18: + dependencies: + mrmime: 2.0.1 + regexparam: 3.0.0 + + wrangler@4.21.2(@cloudflare/workers-types@4.20260313.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 '@cloudflare/unenv-preset': 2.3.3(unenv@2.0.0-rc.17)(workerd@1.20250617.0) @@ -25649,6 +26106,7 @@ snapshots: unenv: 2.0.0-rc.17 workerd: 1.20250617.0 optionalDependencies: + '@cloudflare/workers-types': 4.20260313.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil @@ -25793,6 +26251,15 @@ snapshots: dependencies: zod: 4.2.1 + zod-to-json-schema@3.25.1(zod@4.2.1): + dependencies: + zod: 4.2.1 + optional: true + + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@3.22.3: {} zod@3.25.76: {} diff --git a/prd/mcp-course-authoring/README.md b/prd/mcp-course-authoring/README.md new file mode 100644 index 00000000..892cc6af --- /dev/null +++ b/prd/mcp-course-authoring/README.md @@ -0,0 +1,749 @@ +--- +name: MCP Course Authoring from Structured Payloads +overview: Build an MCP-compatible authoring surface that lets external AI clients such as Claude send normalized course structures into ClassroomIO, save them as reviewable drafts, revise them, and publish them into real ClassroomIO courses. +todos: + - id: prd-review + content: Finalize PRD scope, product policy, and success metrics with stakeholders + status: pending + - id: auth-model + content: Define shared organization automation key system reused by MCP and Public API + status: pending + - id: import-schema + content: Finalize normalized course payload schema shared by prompt and PDF-derived flows + status: pending + - id: import-validation + content: Add validation module for structured draft create, update, and publish requests + status: pending + - id: import-data-model + content: Add draft persistence tables and query layer + status: pending + - id: import-api + content: Add organization-scoped draft and publish routes + status: pending + - id: mcp-package + content: Build MCP package with stdio transport and ClassroomIO tool adapters + status: pending + - id: publish-flow + content: Implement draft-to-course publish orchestration using existing course, section, lesson, and language services + status: pending + - id: idempotency + content: Add idempotency keys and duplicate publish guards for MCP-triggered imports + status: pending + - id: observability + content: Add structured logs, audit trail, and rate limits for MCP tools + status: pending + - id: docs + content: Document MCP tool contracts, auth setup, payload schema, and agent responsibilities + status: pending +isProject: false +--- + +# MCP Course Authoring from Structured Payloads + +## Status + +- Draft + +## Date + +- March 12, 2026 + +## Purpose + +Enable external AI clients such as Claude to create and edit ClassroomIO courses through MCP by sending normalized course structures into ClassroomIO. + +The key product outcome is: + +1. Claude or another agent reads source material externally +2. Claude turns that material into structured course JSON +3. MCP sends that JSON to ClassroomIO +4. ClassroomIO stores it as a draft +5. the user reviews and explicitly publishes it + +This supports prompts like: + +- "Create a complete course on exponential functions in Classroom." +- "Read this PDF, extract the course structure, and publish it into Classroom." + +with one important boundary: + +**In v1, ClassroomIO does not parse or extract PDFs itself.** + +## Problem Statement + +ClassroomIO already has the core CRUD primitives needed to create courses and content, but it does not yet expose a clean automation layer for external AI agents. + +Current limitations: + +- external AI clients have no MCP-compatible tool surface +- there is no normalized course payload contract for external agents +- there is no first-class draft persistence model for AI-generated course structures +- there is no explicit review/publish workflow for machine-generated content +- there is no stable API contract for "here is a complete course structure, store it safely" + +This means agents can only create ClassroomIO content through brittle, record-by-record automation instead of a deliberate authoring pipeline. + +## Core Product Decision + +**Claude or the external agent is responsible for extraction. ClassroomIO is responsible for validation, persistence, review, authorization, and publish.** + +That means: + +- if the source is a PDF, Claude reads it +- if the source is a website, Claude reads it +- if the source is a prompt, Claude expands it +- ClassroomIO only receives structured payloads + +## Primary Use Cases + +### Use Case 1: Prompt to Structured Draft + +A teacher asks Claude to create a course on a topic. Claude reasons about the content and sends a normalized course payload to ClassroomIO through MCP. + +### Use Case 2: PDF to Structured Draft + +A teacher provides one or more PDFs to Claude. Claude extracts the material and converts it into a normalized course payload. MCP sends that payload to ClassroomIO. + +### Use Case 3: Draft Revision + +After initial creation, Claude updates the draft by: + +1. renaming sections +2. rewriting lesson content +3. reordering lessons +4. adding or removing lessons +5. publishing when the teacher confirms + +## Goals + +1. Provide an MCP-compatible authoring surface for external AI clients. +2. Keep ClassroomIO as the source of truth for auth, permissions, validation, persistence, and publishing. +3. Standardize a normalized course payload that any external agent can submit. +4. Make draft review mandatory before publish in v1. +5. Ensure org-scoped, permission-safe authoring with no direct DB access from the MCP server. +6. Support both prompt-derived and PDF-derived content through one shared payload contract. +7. Preserve human control by returning warnings and requiring explicit publish. + +## Non-Goals (v1) + +- Student-facing AI workflows +- Direct DB writes from the MCP server +- Full autonomous publishing without a review step +- ClassroomIO-side PDF extraction, OCR, or parsing +- Async PDF extraction workers +- Cross-organization or cross-course content access +- Full LMS migration parity with Teachable/Thinkific/SCORM in this PRD +- Perfect fidelity for tables, diagrams, formulas, or source formatting + +## Confirmed Product Decisions + +1. MCP is an orchestration layer, not the persistence layer. +2. ClassroomIO API owns validation, authorization, draft storage, and publish. +3. Draft-first is required; AI-generated content is not published immediately in v1. +4. External agents submit structured payloads; ClassroomIO does not ingest raw PDFs in v1. +5. The MCP server should start as a thin tool adapter and not require its own model provider. +6. Lesson long-form content should be stored in lesson language content, not only `lesson.note`. +7. Publish remains an explicit second step. + +## Current-State Audit + +| Capability | Current State | Notes | +| --- | --- | --- | +| Course creation | Exists via `POST /course` | Creates course shell only | +| Section creation | Exists via `POST /course/:courseId/section` | One section at a time | +| Lesson creation | Exists via `POST /course/:courseId/lesson` | One lesson at a time | +| Lesson language content | Exists via lesson-language routes | Good fit for generated lesson body | +| Course content reorder | Exists via content/section/lesson reorder routes | Useful after generation | +| Document upload | Exists for lesson documents | Not needed for v1 MCP ingestion | +| AI course assistant | PRD exists | Focused on in-app chat, not MCP | +| Public API keys | PRD exists | Likely dependency for auth model | +| Draft persistence for AI-generated course structures | Does not exist yet | Must be added | + +## Data Sources Checked + +- [apps/api/src/routes/course/course.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/routes/course/course.ts) +- [apps/api/src/routes/course/section.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/routes/course/section.ts) +- [apps/api/src/routes/course/lesson.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/routes/course/lesson.ts) +- [apps/api/src/routes/course/lesson-language.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/routes/course/lesson-language.ts) +- [apps/api/src/services/course/course.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/services/course/course.ts) +- [apps/api/src/services/course/section.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/services/course/section.ts) +- [apps/api/src/services/lesson/lesson.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/services/lesson/lesson.ts) +- [apps/api/src/services/lesson-language.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/services/lesson-language.ts) +- [packages/utils/src/validation/course/course.ts](/Users/rotimibest/_pros/classroomio/packages/utils/src/validation/course/course.ts) +- [packages/utils/src/validation/lesson/lesson.ts](/Users/rotimibest/_pros/classroomio/packages/utils/src/validation/lesson/lesson.ts) +- [packages/utils/src/validation/lesson/language.ts](/Users/rotimibest/_pros/classroomio/packages/utils/src/validation/lesson/language.ts) +- [prd/ai-course-assistant/README.md](/Users/rotimibest/_pros/classroomio/prd/ai-course-assistant/README.md) +- [prd/public-api/README.md](/Users/rotimibest/_pros/classroomio/prd/public-api/README.md) + +## User Personas + +### Persona A: Solo Teacher + +Wants to turn source material into a structured first course quickly, then lightly edit before publish. + +### Persona B: Training Operator + +Uses Claude to convert internal manuals or SOPs into structured staff training without rebuilding content manually. + +### Persona C: Content Team + +Uses Claude as an operator to draft or refactor course structures programmatically across multiple organizations and topics. + +## Jobs To Be Done + +1. Convert source intent or material into a usable draft course structure. +2. Persist AI-generated course structures safely before publish. +3. Review machine-generated content before it affects learner-facing content. +4. Keep content generation inside org permissions and audit boundaries. +5. Avoid brittle record-by-record automation from external agents. + +## Product Principles + +1. Draft before publish. +2. API-first, not DB-first. +3. One normalized course payload regardless of how the agent derived it. +4. External agents think; ClassroomIO validates and persists. +5. Warnings are first-class output, not hidden logs. + +## Proposed Architecture + +```mermaid +flowchart TB + subgraph client [AI Client] + Claude["Claude or other MCP client"] + Source["Prompt / PDF / Website / Notes"] + end + subgraph mcp [MCP Server] + Tools["MCP tools"] + Adapter["ClassroomIO API adapter"] + end + subgraph api [ClassroomIO API] + Auth["Org-scoped automation key auth"] + DraftRoutes["Draft routes"] + DraftService["Draft orchestration service"] + PublishService["Draft publish service"] + end + subgraph db [Data Layer] + DraftTable["course_import_draft"] + CourseTables["course / section / lesson / lesson_language"] + end + + Source --> Claude + Claude --> Tools + Tools --> Adapter + Adapter --> Auth + Auth --> DraftRoutes + DraftRoutes --> DraftService + DraftService --> DraftTable + DraftService --> PublishService + PublishService --> CourseTables +``` + +## Scope Overview + +### In Scope + +- MCP tool surface for structured course authoring workflows +- organization-scoped authentication for MCP clients +- draft persistence and preview APIs +- publish flow from draft to real course content +- warnings, audit logging, and idempotency + +### Out of Scope + +- raw PDF ingestion by ClassroomIO +- OCR, parsing, or extraction jobs +- rich in-dashboard chat UX +- student preview or learner-side AI guidance + +## Functional Requirements + +### FR-1 MCP Tool Surface + +The MCP server must expose a minimal, stable set of tools for AI clients: + +- `create_course_draft` +- `get_course_draft` +- `update_course_draft` +- `publish_course_draft` +- `list_org_courses` +- `get_course_structure` + +The MCP server must not write directly to the database. + +### FR-2 Authentication and Scoping + +All MCP actions must be authenticated and scoped to an organization. + +Requirements: + +- use organization-scoped automation keys +- reuse the same underlying key system as the Public API +- allow MCP to use a narrower scope preset than generic API access +- every request resolves a single org context +- mutations require org admin or authorized course team membership +- cross-org access must be rejected + +### FR-3 Normalized Course Payload + +All external agents must submit the same normalized course payload. + +Minimum payload shape: + +- course metadata +- sections +- lessons +- lesson content by locale +- optional exercises +- source references +- warnings + +### FR-4 Draft Review + +Generated content must remain in draft state until explicitly published. + +The draft API must allow: + +- fetching current draft +- patching draft metadata +- adding/removing/reordering sections and lessons +- updating lesson titles and language content +- inspecting warnings before publish + +### FR-5 Structured Payload Ingestion + +Users must be able to create a draft course by submitting a normalized course payload. + +The API must not care whether the payload came from: + +- a direct prompt +- a PDF processed by Claude +- a website processed by Claude +- manually authored JSON + +### FR-6 Publish Flow + +Publishing a draft must create or update real ClassroomIO content through the existing service layer. + +Publish orchestration should: + +1. create course shell +2. create sections +3. create lessons +4. create lesson-language content +5. optionally create exercises in later scope +6. return created IDs and publish summary + +### FR-7 Idempotency and Retry Safety + +MCP-triggered operations must be safe to retry. + +Requirements: + +- accept idempotency key on draft creation and publish +- prevent duplicate course creation on publish retries + +### FR-8 Observability + +The system must expose enough detail for debugging and support. + +Requirements: + +- structured logs per draft create/update/publish action +- audit trail for actor, org, tool, and created course IDs +- warning list available to the AI client and internal admins +- rate limits per org and per key + +## Data Model + +### New Table: `course_import_draft` + +Fields: + +- `id` +- `organizationId` +- `createdByProfileId` +- `sourceType` +- `status` +- `title` +- `locale` +- `idempotencyKey` +- `summary` +- `draft` +- `warnings` +- `sourceArtifacts` +- `publishedCourseId` +- timestamps + +This table stores the normalized draft payload and the publish result. + +## Normalized Payload Contract + +```ts +type CourseDraft = { + course: { + title: string; + description: string; + type: 'LIVE_CLASS' | 'SELF_PACED'; + locale: string; + metadata?: Record; + }; + sections: Array<{ + externalId: string; + title: string; + order: number; + }>; + lessons: Array<{ + externalId: string; + sectionExternalId: string; + title: string; + order: number; + isUnlocked?: boolean; + public?: boolean; + }>; + lessonLanguages: Array<{ + lessonExternalId: string; + locale: string; + content: string; + }>; + exercises?: Array>; + sourceReferences?: Array<{ + type: 'prompt' | 'pdf'; + label: string; + pageStart?: number; + pageEnd?: number; + }>; + warnings: Array<{ + code: string; + message: string; + severity: 'info' | 'warning' | 'error'; + }>; +}; +``` + +## Proposed API Design + +### Validation + +Add: + +- `packages/utils/src/validation/course-import/course-import.ts` + +Schemas: + +- `ZCourseImportDraftCreate` +- `ZCourseImportDraftGetParam` +- `ZCourseImportDraftUpdate` +- `ZCourseImportDraftPublish` + +### Routes + +Add organization-scoped route group: + +- `apps/api/src/routes/organization/course-import.ts` + +Suggested endpoints: + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/organization/course-import/drafts` | Create draft from normalized payload | +| `GET` | `/organization/course-import/drafts/:draftId` | Get draft | +| `PUT` | `/organization/course-import/drafts/:draftId` | Update draft | +| `POST` | `/organization/course-import/drafts/:draftId/publish` | Publish draft into course | + +All routes must return a single response shape and follow existing route conventions. + +### Services + +Add: + +- `apps/api/src/services/course-import/course-import.ts` + +Responsibilities: + +- draft persistence +- validation of structured payloads +- publish orchestration using current course/section/lesson/language services + +## MCP Server Design + +### Placement + +Recommended: + +- `apps/mcp` +or +- `packages/mcp` + +Start with stdio transport for Claude Desktop and local agent usage. + +### Responsibilities + +- expose MCP tools +- validate tool input +- forward requests to ClassroomIO API +- format structured responses for the AI client + +### Non-Responsibilities + +- direct DB access +- authorization decisions independent of ClassroomIO +- content extraction from source materials +- PDF parsing or OCR + +### Tool Contracts + +#### `create_course_draft` + +Input: + +- `organizationId` +- normalized `draft` +- `sourceType` +- `idempotencyKey?` +- `summary?` +- `sourceArtifacts?` + +Output: + +- `draftId` +- `summary` +- `warnings` + +#### `get_course_draft` + +Input: + +- `draftId` + +Output: + +- stored draft payload +- summary +- warnings +- publish status + +#### `update_course_draft` + +Input: + +- `draftId` +- patch payload + +Output: + +- updated summary +- warnings + +#### `publish_course_draft` + +Input: + +- `draftId` +- optional overrides for title/description/type/metadata + +Output: + +- `courseId` +- `createdSections` +- `createdLessons` +- `localeCount` + +## Shared API Key Design + +MCP and Public API should reuse the same underlying key infrastructure. + +They should not reuse the same default secret for every use case. + +Recommended model: + +- one shared `organization_api_key` table +- one shared bearer-auth middleware +- different key presets by integration type + +Recommended key fields: + +- `id` +- `organizationId` +- `type`: `mcp | api | zapier` +- `label` +- `secretPrefix` +- `secretHash` +- `scopes` +- `createdByProfileId` +- `lastUsedAt` +- `expiresAt` +- `revokedAt` +- `createdAt` +- `updatedAt` + +Recommended key handling: + +- show raw secret once on creation +- store only the hash +- allow multiple active keys per organization +- encourage one key per integration or agent + +Recommended preset policy: + +- `mcp` preset: + - draft create/read/update + - draft publish +- `api` preset: + - broader route-specific scopes selected by admin + +## Route Design + +Shared management routes should live under organization automation, not a separate MCP-only surface. + +Recommended management routes: + +- `GET /organization/automation/keys` +- `POST /organization/automation/keys` +- `DELETE /organization/automation/keys/:keyId` +- `POST /organization/automation/keys/:keyId/rotate` + +Recommended request fields for create: + +- `type` +- `label` +- `preset` +- `scopes` optional for custom API keys +- `expiresAt` optional + +Recommended auth flow for machine requests: + +1. client sends `Authorization: Bearer ` +2. middleware hashes key +3. API resolves organization from stored key row +4. API attaches scopes and key metadata to context +5. route checks scope and org permissions + +## Automation UI Design + +The management UI should live in the org sidebar as `Automation`. + +Tabs: + +- `MCP` +- `Zapier` disabled, coming soon +- `API` disabled, coming soon until public API ships + +MCP tab responsibilities: + +- explain what MCP does +- generate org-scoped MCP keys +- list active MCP keys +- revoke or rotate keys +- show setup snippets for: + - Claude Code + - Codex + - Cursor + +Permissions: + +- org admins can create, rotate, revoke +- teachers can open the page but see the controls disabled +- all server mutations remain admin-only + +## UX and Review Model + +The review loop is: + +1. external agent creates draft +2. user inspects summary and warnings +3. external agent revises draft if needed +4. user explicitly publishes + +The AI client should never assume silent publish in v1. + +## Failure Modes and Warnings + +The system must capture and expose at least these issues: + +- duplicate external IDs +- lessons referencing missing sections +- lesson-language entries referencing missing lessons +- empty or invalid titles/content +- invalid metadata shape +- duplicate publish attempts + +Publishing should be blocked on validation errors. Informational warnings may still allow publish. + +## Security Considerations + +1. Reject cross-org access for all draft and publish routes. +2. Enforce org admin or equivalent authoring permissions. +3. Log key ID, key type, organization, and output course IDs for all MCP-triggered publishes. +4. Apply rate limits per org and per key. +5. Keep the MCP server outside the database trust boundary. + +## Success Metrics + +### Product Metrics + +1. Time from agent output to first draft available +2. Draft-to-publish conversion rate +3. Average manual edits before publish +4. Percentage of generated drafts published within 24 hours + +### Quality Metrics + +1. Percentage of drafts requiring no structural edits +2. Publish failure rate +3. Duplicate publish rate +4. Support tickets related to malformed structure payloads + +## Rollout Plan + +### Phase 1: Draft and Publish API + +Ship: + +- normalized payload schema +- draft create endpoint +- draft update endpoint +- publish endpoint + +### Phase 2: MCP Server + +Ship: + +- stdio MCP server +- auth wiring +- tool contracts for create/get/update/publish + +### Phase 3: Hardening + +Ship: + +- idempotency keys +- richer warnings +- observability +- docs and examples + +## Dependencies + +1. Existing course, section, lesson, and lesson-language services remain the publish target. +2. Shared organization automation key infrastructure must be implemented. +3. External agent must generate normalized course payloads that match the schema. + +## Open Questions + +1. Should `publish_course_draft` remain a separate tool only, or support an explicit `publish: true` flag during draft creation? +2. Should exercise payloads be part of v1 or deferred until lesson drafting stabilizes? +3. What locale set should be supported by default? +4. Should cloud-only restrictions apply, similar to the AI assistant PRD? +5. Should the shared key-management routes ship before public `/v1/*` API routes, so MCP can adopt them first? + +## Recommendation Summary + +The correct v1 shape is: + +1. thin MCP server +2. ClassroomIO-owned draft/publish API +3. external agent owns source extraction +4. explicit draft review before publish +5. publish via existing content services, not custom DB writes + +That keeps the system small, matches your clarified boundary, and gives Claude a clean automation surface without forcing ClassroomIO to solve PDF extraction in v1. diff --git a/prd/mcp-course-authoring/consumption-spec.md b/prd/mcp-course-authoring/consumption-spec.md new file mode 100644 index 00000000..ab9131e7 --- /dev/null +++ b/prd/mcp-course-authoring/consumption-spec.md @@ -0,0 +1,357 @@ +# MCP Consumption Spec + +## Purpose + +Define exactly how an MCP client such as Claude Desktop or another agent runtime should consume the ClassroomIO MCP server. + +This spec assumes: + +1. the agent performs source extraction externally +2. the MCP server only accepts structured course payloads +3. ClassroomIO API is the system of record + +## Consumption Model + +```text +User prompt/source material + -> Claude or agent + -> MCP tool call + -> ClassroomIO API + -> ClassroomIO DB +``` + +The MCP server is consumed as a tool surface, not as a conversational model. + +## Agent Responsibilities + +The agent must: + +1. read source material such as PDFs, notes, prompts, or websites +2. infer a structured course outline +3. construct a normalized payload that matches the draft schema +4. call MCP tools with that payload +5. handle validation errors and revise payloads when needed +6. only publish after explicit user confirmation + +The agent must not: + +1. write directly to the ClassroomIO database +2. assume ClassroomIO will parse raw PDFs +3. assume drafts are automatically published + +## Authentication Model + +### Target model + +Use an organization-scoped MCP key generated from the org `Automation -> MCP` tab. + +That key should come from the same shared key infrastructure later reused by the Public API. + +The MCP server forwards that credential to ClassroomIO on every request. + +Example environment: + +```bash +CLASSROOMIO_API_URL=https://api.classroomio.example +CLASSROOMIO_API_KEY=cio_mcp_xxxxx +``` + +Recommended request header: + +```text +Authorization: Bearer cio_mcp_xxxxx +``` + +### Current bootstrap implementation + +Until the shared organization key system lands, the local package uses: + +```text +Authorization: Bearer +cio-org-id: +x-cio-actor-id: +``` + +That is an implementation bridge, not the intended long-term auth model. + +## MCP Transport + +### v1 + +- `stdio` + +This is the best default for Claude Desktop and local agent environments. + +### Future + +- SSE or HTTP transport if remote/shared infrastructure is needed + +## Tool Surface + +### `create_course_draft` + +Creates a reviewable draft from a normalized payload. + +Input: + +```json +{ + "sourceType": "prompt", + "idempotencyKey": "optional-key", + "summary": { + "sourceLabel": "Exponential Functions PDF" + }, + "sourceArtifacts": [ + { + "type": "pdf", + "label": "chapter-1.pdf" + } + ], + "draft": { + "course": { + "title": "Exponential Functions", + "description": "A structured introduction to exponential functions.", + "type": "SELF_PACED", + "locale": "en" + }, + "sections": [ + { + "externalId": "sec-1", + "title": "Foundations", + "order": 0 + } + ], + "lessons": [ + { + "externalId": "lesson-1", + "sectionExternalId": "sec-1", + "title": "What Is an Exponential Function?", + "order": 0 + } + ], + "lessonLanguages": [ + { + "lessonExternalId": "lesson-1", + "locale": "en", + "content": "

Lesson content here

" + } + ], + "warnings": [] + } +} +``` + +Output: + +```json +{ + "draftId": "uuid", + "status": "DRAFT", + "summary": { + "sectionCount": 1, + "lessonCount": 1, + "localeCount": 1, + "warningCount": 0 + }, + "warnings": [] +} +``` + +### `get_course_draft` + +Fetches the current stored draft. + +Input: + +```json +{ + "draftId": "uuid" +} +``` + +Output: + +```json +{ + "id": "uuid", + "status": "DRAFT", + "draft": { + "...": "normalized payload" + }, + "warnings": [] +} +``` + +### `update_course_draft` + +Updates a stored draft with revised structure or content. + +Input: + +```json +{ + "draftId": "uuid", + "draft": { + "...": "updated normalized payload" + } +} +``` + +Output: + +```json +{ + "draftId": "uuid", + "status": "DRAFT", + "summary": { + "sectionCount": 3, + "lessonCount": 12, + "localeCount": 1, + "warningCount": 1 + }, + "warnings": [ + { + "code": "LOW_CONFIDENCE_BOUNDARY", + "message": "Section split was inferred.", + "severity": "warning" + } + ] +} +``` + +### `publish_course_draft` + +Publishes a stored draft into real ClassroomIO entities. + +Input: + +```json +{ + "draftId": "uuid", + "title": "Optional override", + "description": "Optional override", + "type": "SELF_PACED" +} +``` + +Output: + +```json +{ + "courseId": "uuid", + "createdSections": 3, + "createdLessons": 12, + "localeCount": 1 +} +``` + +## Expected Agent Workflow + +### Flow A: Prompt-Based Course Creation + +1. User asks Claude to create a course. +2. Claude plans the structure. +3. Claude calls `create_course_draft`. +4. Claude presents the summary to the user. +5. User requests revisions if needed. +6. Claude calls `update_course_draft`. +7. User confirms publish. +8. Claude calls `publish_course_draft`. + +### Flow B: PDF-Based Course Creation + +1. User provides PDF to Claude. +2. Claude extracts and structures the content externally. +3. Claude builds normalized payload. +4. Claude calls `create_course_draft`. +5. Claude presents draft summary and warnings. +6. Claude revises if needed. +7. Claude publishes only after user confirmation. + +## Draft Schema Rules + +The agent must ensure: + +1. `section.externalId` values are unique +2. `lesson.externalId` values are unique +3. every `lesson.sectionExternalId` references an existing section +4. every `lessonLanguage.lessonExternalId` references an existing lesson +5. all required text fields are non-empty + +If these rules are violated, ClassroomIO should reject the request with validation errors. + +## Error Handling Contract + +### Validation Errors + +The MCP client should expect 400-level errors when: + +- the payload shape is invalid +- section or lesson references are broken +- required fields are missing + +Expected behavior: + +1. surface the error to the agent +2. revise payload +3. retry with corrected structure + +### Authorization Errors + +The MCP client should expect: + +- `401` for missing or invalid auth +- `403` for valid auth but insufficient org permission + +### Publish Errors + +If publish fails: + +1. do not assume partial success +2. inspect returned error +3. fetch the draft again if needed +4. retry only if the failure is clearly safe to retry + +## Idempotency Guidance + +The MCP client should send an `idempotencyKey` on draft creation when: + +- the same source material may be retried +- the same user prompt may be replayed +- the client cannot guarantee exactly-once execution + +Use one stable key per logical draft creation request. + +## Recommended User Experience in Claude + +The agent should present: + +1. course title +2. section count +3. lesson count +4. warnings +5. a publish confirmation prompt before calling `publish_course_draft` + +The agent should not auto-publish immediately after draft creation. + +## Minimal v1 MCP Server Requirements + +The MCP server should: + +1. expose the draft/publish tools above +2. validate required inputs before forwarding +3. forward auth and org context to ClassroomIO +4. return structured JSON outputs to the client +5. avoid embedding model logic + +## Future Extensions + +Possible future tools: + +- `list_course_drafts` +- `delete_course_draft` +- `clone_course_structure` +- `add_exercises_to_draft` +- `preview_publish_diff` + +These are not required for v1. diff --git a/prd/mcp-course-authoring/implementation-plan.md b/prd/mcp-course-authoring/implementation-plan.md new file mode 100644 index 00000000..d262f5f0 --- /dev/null +++ b/prd/mcp-course-authoring/implementation-plan.md @@ -0,0 +1,56 @@ +# MCP Course Authoring from Structured Payloads Implementation Plan + +## Scope Lock (v1) + +1. The MCP server is a thin API adapter, not a persistence layer. +2. Draft review is mandatory before publish. +3. External agents submit normalized course payloads. +4. ClassroomIO does not parse PDFs or source material in v1. +5. Publish uses existing ClassroomIO services for course, section, lesson, and lesson language creation. +6. Authentication is org-scoped and must reject cross-org mutations. + +## Delivery Phases + +1. Phase A: Payload schema, validation, and draft persistence +2. Phase B: Draft create/get/update API +3. Phase C: Draft publish orchestration +4. Phase D: MCP server and tool contracts +5. Phase E: Observability, auth hardening, and idempotency + +## Ticket Breakdown + +| ID | Area | Task | Key Files | Dependencies | Done When | +| --- | --- | --- | --- | --- | --- | +| MCP-A1 | Validation | Add structured course payload validation module | `packages/utils/src/validation/course-import/course-import.ts` | None | Schemas exported and built | +| MCP-A2 | DB | Add draft persistence table | `packages/db/src/schema.ts` | None | Draft schema updated | +| MCP-A3 | DB | Add draft query module | `packages/db/src/queries/course-import/course-import.ts` | MCP-A2 | CRUD/query helpers exist | +| MCP-A4 | API | Add draft route skeleton | `apps/api/src/routes/organization/course-import.ts` | MCP-A1 | Route group registered | +| MCP-B1 | API | Implement draft create service | `apps/api/src/services/course-import/course-import.ts` | MCP-A4 | Structured payload creates draft | +| MCP-B2 | API | Implement draft fetch/update endpoints | `apps/api/src/routes/organization/course-import.ts` | MCP-B1 | Draft can be reviewed and edited | +| MCP-C1 | API | Implement draft publish service | `apps/api/src/services/course-import/course-import.ts` | MCP-B2 | Draft creates course, sections, lessons, languages | +| MCP-C2 | API | Add idempotent publish behavior | `apps/api/src/services/course-import/course-import.ts` | MCP-C1 | Retries do not duplicate courses | +| MCP-D1 | MCP | Scaffold MCP package | `apps/mcp/` or `packages/mcp/` | MCP-B2 | Stdio server runs locally | +| MCP-D2 | MCP | Add `create_course_draft` tool | MCP server tool files | MCP-D1, MCP-B1 | Claude can create drafts | +| MCP-D3 | MCP | Add `get_course_draft` and `update_course_draft` tools | MCP server tool files | MCP-B2 | Claude can inspect and revise drafts | +| MCP-D4 | MCP | Add `publish_course_draft` tool | MCP server tool files | MCP-C2 | Claude can publish reviewed drafts | +| MCP-E1 | Security | Enforce org-scoped auth and rate limits | middleware + routes | MCP-A4 | Unauthorized access returns 401/403 | +| MCP-E2 | Observability | Add structured logging and audit trail | services + middleware | MCP-C1 | Draft and publish actions are traceable | +| MCP-E3 | Docs | Document setup and consumption contract | `prd/mcp-course-authoring/` and docs follow-up | MCP-D4 | Operator docs exist | + +## Suggested First Sprint Slice + +1. Draft schema and table +2. Draft create endpoint +3. Draft get/update endpoint +4. Draft publish endpoint +5. Minimal MCP server with: + - `create_course_draft` + - `get_course_draft` + - `publish_course_draft` + +## Build and Verification Commands + +1. `pnpm --filter @cio/utils build` +2. `pnpm --filter @cio/db build` +3. `pnpm --filter @cio/api build` +4. Build the MCP package/app once created diff --git a/prd/public-api/README.md b/prd/public-api/README.md index 9505aab5..6a5400e2 100644 --- a/prd/public-api/README.md +++ b/prd/public-api/README.md @@ -1,6 +1,6 @@ --- name: Public API with Keys -overview: Build a versioned public API (v1) authenticated via organization-scoped API keys, with course CRUD, course export, student/audience CRUD endpoints, plus a dashboard Automation page with API key management UI. +overview: Build a versioned public API (v1) authenticated via shared organization-scoped automation keys reused by MCP and Public API, with course CRUD, course export, student/audience CRUD endpoints, plus a dashboard Automation page with MCP, Zapier, and API tabs. todos: - id: db-schema content: Add `api_key` table to schema.ts, update relations, generate migration @@ -12,13 +12,13 @@ todos: content: Build apiKeyMiddleware that authenticates via Bearer token and sets orgId in context status: pending - id: validation-schemas - content: Add Zod schemas for API key management and public API input validation + content: Add Zod schemas for shared automation key management and public API input validation status: pending - id: api-key-service content: Create api-key service (key generation, hashing, CRUD) status: pending - id: api-key-routes - content: Create session-authenticated API key management routes (POST/GET/DELETE /api-key) + content: Create session-authenticated automation key management routes reused by MCP and Public API status: pending - id: v1-course-service content: Create v1 course service (list, get, create, update, delete, export, students) @@ -39,7 +39,7 @@ todos: content: Add Automation nav item to org-navigation.ts status: pending - id: automation-page - content: Create automation page with Tabs (API/Zapier/Webhooks), free plan gate, API key display + content: Create automation page with Tabs (MCP/Zapier/API), admin-only controls, and key setup flows status: pending - id: automation-api-class content: Create frontend API class for api-key management