diff --git a/.env.example b/.env.example index ffbdddde..d1f2cf6c 100644 --- a/.env.example +++ b/.env.example @@ -100,15 +100,12 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000 # GITHUB_FEEDBACK_REPO=reqcore/reqcore # ─── Optional: Analytics (PostHog) ────────────────────────────────────────── -# Privacy-focused product analytics & feature flags powered by PostHog. +# Privacy-focused product analytics powered by PostHog. # Get your project API key from https://posthog.com → Project settings. # Users must accept the consent banner before any events are captured (GDPR). # POSTHOG_PUBLIC_KEY=phc_... # EU data center (default). Use https://us.i.posthog.com for US. # POSTHOG_HOST=https://eu.i.posthog.com -# Personal API key with "Feature Flags: read" scope. When set, the server -# evaluates feature flags locally (no per-request HTTP round trip). -# POSTHOG_FEATURE_FLAGS_KEY=phx_... # ─── Optional: Platform AI gateway (OpenRouter) ───────────────────────────── # When set, organizations that haven't added their own AI key fall back to this @@ -122,15 +119,9 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000 # Global platform-wide daily AI spend cap in USD — the runaway-loop kill-switch. # Trips loudly long before a bug can empty your account. Default: 25 # AI_DAILY_SPEND_CAP_USD=25 - -# ─── Optional: Feature Flag Overrides (no PostHog required) ───────────────── -# Force any flag on or off without running PostHog. The full list of available -# flags lives in shared/feature-flags.ts. Variable name pattern: -# FEATURE_FLAG_ -# Accepted values: true / false / 1 / 0 / on / off (or a variant key for -# multivariate flags). Env overrides win over PostHog rollouts. -# Example — enable the new chatbot experience for everyone on this instance: -# FEATURE_FLAG_CHATBOT_EXPERIENCE=true +# Lifetime number of chatbot prompts available to hosted Free workspaces. +# Count-based: prompt/token length does not change the charge. Default: 20 +# AI_FREE_PLAN_CHATBOT_TURN_LIMIT=20 # ─── Optional: OIDC SSO (Keycloak, Authentik, Authelia, Okta, etc.) ────────── # Enable Single Sign-On via any OIDC-compliant identity provider. diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2f6067..c5ee0fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com). Categories: **Add ### Changed +* **billing:** make bring-your-own-AI-key (BYOK) a Solo-and-above capability instead of a free-plan feature — a free org that brought its own key got the uncapped assistant and uncapped shortlists for nothing, which is what the entry plan sells. Only *creating* a config is gated: free orgs that configured a key before this change keep editing and using it, and grandfathered orgs are unaffected (BYOK is their only route to AI at all). * **licensing:** re-open Reqcore as open-core — AGPLv3 for the core app, with a new [`ee/`](ee) directory (Nuxt layer) for paid, cloud-only features under a separate commercial license. Self-hosting is supported again, best-effort and unsupported (see [SELF-HOSTING.md](SELF-HOSTING.md)). Moved the already plan-gated SSO (`ee/server/api/sso/`), org-wide audit log (`ee/server/api/activity-log/index.get.ts`), source-analytics (`ee/server/api/source-tracking/stats.get.ts`), and AI-analysis dashboard (`ee/server/api/ai-analysis/stats.get.ts`) endpoints out of the AGPL tree and into `ee/` so the license split matches what's actually gated; the underlying tables and the ungated candidate timeline/activity feed/tracking-link CRUD stay in core. ### Added +* **assistant:** ship the AI assistant on every plan with no rollout flag. It runs on the platform OpenRouter key by default — so an org gets a working assistant without configuring anything — or on the org's own key (BYOK), which is never budget-capped. Free orgs get a lifetime allowance of 20 assistant messages (`AI_FREE_PLAN_CHATBOT_TURN_LIMIT`), metered separately from the free AI-shortlist allowance and surfaced as a meter on the billing page; paid orgs are metered by the monthly AI budget instead. Platform-paid turns are recorded in a new `ai_usage_event` ledger and count against the org's monthly AI budget and the global daily kill-switch alongside analysis runs. Grandfathered orgs stay BYOK-only. * **GDPR retention:** add a shared candidate-retention runner with a daily Nitro task, external cron endpoint, instance-wide emergency switch, quarantine restoration on renewed public engagement, localized administration UI, and computed expiry visibility. * **GDPR erasure:** remove application-linked comments, custom properties, and activity records in addition to the candidate database graph and S3 objects. * **blog:** add Cluster 8 career page articles — pillar (career-page-that-converts) and two supporting articles (career-page-seo, google-for-jobs-structured-data) diff --git a/DATA-RETENTION.md b/DATA-RETENTION.md index 59739da6..f5682c4c 100644 --- a/DATA-RETENTION.md +++ b/DATA-RETENTION.md @@ -109,6 +109,12 @@ their normal rotation schedule rather than being purged on demand (the standard GDPR posture). **After restoring any backup, re-run the cleanup job** so that candidates past their purge date are erased again and not silently resurrected. +Chatbot conversations have a separate 180-day inactivity retention period. The +built-in daily retention task deletes expired conversations and their messages, +tool metadata, sources, and typed entity references through database cascades. +Candidate export and erasure use those typed references so candidate-derived +chatbot messages are included in access exports and redacted during erasure. + ## Controller / processor split The organization is the **data controller**. For data-subject access, erasure, or diff --git a/SECURITY.md b/SECURITY.md index f236dd56..aee59887 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -37,6 +37,21 @@ Given Reqcore's architecture, the highest-priority findings include: - Secret leakage or insecure default configuration - Injection vulnerabilities in API or DB access paths +## Deployment Hardening + +The application enforces an 8 MB chatbot-file ceiling while streaming the +request and revalidates custom AI endpoint DNS before every outbound request. +Production deployments should also enforce these controls outside the Node +process: + +- Set the reverse proxy request-body limit for `/api/chatbot/upload` to no more + than 8.1 MB (the small margin is for multipart headers). +- Deny application-container egress to loopback, link-local, RFC1918, + carrier-grade NAT, cloud metadata, and other internal network ranges. Permit + HTTPS egress only to approved AI providers where an allowlist is practical. +- Keep redirect following disabled in any outbound proxy used for custom AI + endpoints. + ## Safe Harbor If you act in good faith, avoid privacy violations and service disruption, and give us reasonable time to resolve findings before disclosure, we will treat your research as authorized and welcomed. diff --git a/app/app.vue b/app/app.vue index 3e920499..08a3921f 100644 --- a/app/app.vue +++ b/app/app.vue @@ -3,6 +3,15 @@ const i18nHead = useLocaleHead({ seo: true, }) +// @nuxtjs/i18n exposes these as Record even though it only +// emits alternate and canonical links. Keep that runtime shape explicit for +// Unhead 3's stricter discriminated link types. +type LocaleHeadLink = + | { rel: 'alternate', href: string, hreflang: string } + | { rel: 'canonical', href: string } +type LocaleHeadMeta = { property: string, content: string } +type LocaleHtmlAttrs = { dir?: 'auto' | 'ltr' | 'rtl', lang?: string } + // Job listings/detail and branded career pages serve recruiter-authored, // single-language content under every locale prefix, so their localized // variants are noindex (see nuxt.config routeRules + the pages' robots meta). @@ -20,9 +29,9 @@ const i18nLinks = computed(() => : i18nHead.value.link) useHead(() => ({ - htmlAttrs: i18nHead.value.htmlAttrs, - link: i18nLinks.value, - meta: i18nHead.value.meta, + htmlAttrs: i18nHead.value.htmlAttrs as LocaleHtmlAttrs, + link: i18nLinks.value as LocaleHeadLink[], + meta: i18nHead.value.meta as LocaleHeadMeta[], })) // Blocking inline script to apply dark mode before first paint (prevents white diff --git a/app/components/AppTopBar.vue b/app/components/AppTopBar.vue index 54917581..9f3d1a21 100644 --- a/app/components/AppTopBar.vue +++ b/app/components/AppTopBar.vue @@ -100,8 +100,6 @@ const { data: feedbackConfig } = useFetch('/api/feedback/config', { const isFeedbackEnabled = computed(() => feedbackConfig.value?.enabled === true) -const showChatbot = useFeatureFlagEnabled('chatbot-experience') - const jobTabs = computed(() => { if (!activeJobId.value) return [] const base = `/dashboard/jobs/${activeJobId.value}` @@ -123,13 +121,14 @@ const jobTabs = computed(() => { // Main navigation // ───────────────────────────────────────────── -const mainNav: Array<{ label: string; to: string; icon: typeof Briefcase; exact: boolean; comingSoon?: boolean; feature?: PlanFeature }> = [ +const navItems: Array<{ label: string; to: string; icon: typeof Briefcase; exact: boolean; comingSoon?: boolean; feature?: PlanFeature }> = [ { label: 'Dashboard', to: '/dashboard', icon: LayoutDashboard, exact: true }, { label: 'Jobs', to: '/dashboard/jobs', icon: Briefcase, exact: false }, { label: 'Candidates', to: '/dashboard/candidates', icon: Users, exact: false }, { label: 'Applications', to: '/dashboard/applications', icon: FileText, exact: false }, { label: 'Inbox', to: '/dashboard/inbox', icon: Inbox, exact: true, feature: 'candidateMessaging' }, { label: 'Interviews', to: '/dashboard/interviews', icon: Calendar, exact: false }, + { label: 'Assistant', to: '/dashboard/chatbot', icon: MessageCircle, exact: false, feature: 'chatbot' }, { label: 'Timeline', to: '/dashboard/timeline', icon: History, exact: true, feature: 'activityTimeline' }, { label: 'Source Tracking', to: '/dashboard/source-tracking', icon: Radio, exact: true, feature: 'sourceAnalytics' }, { label: 'AI Analysis', to: '/dashboard/ai-analysis', icon: Sparkles, exact: true, feature: 'aiAnalytics' }, @@ -143,37 +142,15 @@ function isNavLocked(item: { feature?: PlanFeature }): boolean { return item.feature != null && !hasFeature(item.feature) } -// Items shown only when their feature flag is enabled. Filtered into mainNav -// reactively so the gating happens at render time (PostHog flags load async). -const flaggedNav = computed(() => { - const items: Array<{ label: string; to: string; icon: typeof Briefcase; exact: boolean; afterLabel: string }> = [] - if (showChatbot.value) { - items.push({ label: 'Assistant', to: '/dashboard/chatbot', icon: MessageCircle, exact: false, afterLabel: 'AI Analysis' }) - } - return items -}) - -const navItems = computed(() => { - const merged = [...mainNav] - for (const item of flaggedNav.value) { - const idx = merged.findIndex((n) => n.label === item.afterLabel) - const insertAt = idx >= 0 ? idx + 1 : merged.length - merged.splice(insertAt, 0, { - label: item.label, to: item.to, icon: item.icon, exact: item.exact, - }) - } - return merged -}) - function isActiveRoute(to: string, exact: boolean) { const localizedTo = localePath(to) if (exact) return route.path === localizedTo return route.path === localizedTo || route.path.startsWith(`${localizedTo}/`) } -const primaryNavLabels = ['Dashboard', 'Jobs', 'Candidates', 'Applications', 'Inbox', 'Interviews', 'Settings'] -const primaryNavItems = computed(() => navItems.value.filter(i => primaryNavLabels.includes(i.label))) -const moreNavItems = computed(() => navItems.value.filter(i => !primaryNavLabels.includes(i.label))) +const primaryNavLabels = ['Dashboard', 'Jobs', 'Candidates', 'Applications', 'Inbox', 'Interviews', 'Assistant', 'Settings'] +const primaryNavItems = computed(() => navItems.filter(i => primaryNavLabels.includes(i.label))) +const moreNavItems = computed(() => navItems.filter(i => !primaryNavLabels.includes(i.label))) // Close menus on route change watch(() => route.path, () => { diff --git a/app/components/ChatbotModelPicker.vue b/app/components/ChatbotModelPicker.vue index 38f9b395..ba54a9bb 100644 --- a/app/components/ChatbotModelPicker.vue +++ b/app/components/ChatbotModelPicker.vue @@ -2,15 +2,50 @@ /** * ChatbotModelPicker * - * Sibling of ChatbotAgentPicker. Lets the user pick which AI configuration - * powers the next message. "Default" sits at the top and resolves server-side - * (org's default chatbot config). Includes a "Manage models…" entry that - * emits `manage` so the host can navigate to /dashboard/settings/ai. + * Sibling of ChatbotAgentPicker. Chooses what powers the next message, from one + * flat menu with two halves: + * + * - **Reqcore AI** — the vetted catalogue (`shared/chatbot-models.ts`), grouped + * by lab and capability tier. These run on the platform key and spend the org's + * assistant credits, so each carries its credit multiplier. + * - **Your own keys** — the org's BYOK configs, billed by their provider, not + * in credits. They bring their own model id, so no multiplier applies. + * + * The two halves are one list because they answer the same question ("which + * brain?"), and because picking a catalogue model *is* picking the platform + * engine — splitting them into two controls would make that coupling invisible. + * + * Multipliers are relative to the default model and shown as "about N×": the + * real charge is metered from the turn's own tokens (see `creditMultiplier`). + * Intelligence rescales the top 20% of the supplied LM Arena text leader's + * score onto 0–100. It is a relative benchmark indicator, not a claim that + * general intelligence can be measured as an absolute percentage. */ -import { Brain, Check, ChevronUp, Settings, Star, AlertTriangle } from 'lucide-vue-next' +import { Brain, Check, ChevronUp, Settings, Star, AlertTriangle, KeyRound, LockKeyhole } from 'lucide-vue-next' +import { PLATFORM_ENGINE_ID } from '~~/shared/chatbot' +import { + CHATBOT_MODEL_TIER_LABELS, + CHATBOT_MODEL_VENDOR_LABELS, + FREE_PLAN_CHATBOT_MODEL_ID, + chatbotModelChoices, + type ChatbotModelChoice, + type ChatbotModelVendor, +} from '~~/shared/chatbot-models' -const { aiConfigs, selectedAiConfigId, currentConversationId, updateConversation } = useChatbot() +const { + aiConfigs, + selectedAiConfigId, + selectedModel, + selectedModelChoice, + defaultModel, + setDefaultModel, + platformAiConfig, + platformEngineActive, + currentConversationId, + updateConversation, +} = useChatbot() const emit = defineEmits<{ manage: [] }>() +const { isFree } = useChatbotQuota() const open = ref(false) const root = useTemplateRef('root') @@ -20,22 +55,90 @@ const selectedConfig = computed(() => ) const defaultChatbotConfig = computed(() => aiConfigs.value.find((c) => c.isDefaultChatbot) ?? null) +/** Catalogue models are only offered when the platform engine is usable at all. */ +const platformAvailable = computed(() => + Boolean(platformAiConfig.value) && platformAiConfig.value?.isEnabled !== false, +) + +/** + * A BYOK config's provider mapped onto a catalogue vendor, so the org's own + * OpenAI key shows the same mark as the platform's OpenAI models. Returns null + * for providers with no lab of their own (`openrouter`, custom endpoints) — + * those keep the generic key icon rather than borrowing someone's brand. + */ +const BYOK_PROVIDER_VENDORS: Record = { + openai: 'openai', + anthropic: 'anthropic', + google: 'google', + xai: 'xai', +} + +function byokVendor(provider: string): ChatbotModelVendor | null { + return BYOK_PROVIDER_VENDORS[provider] ?? null +} + +const modelsByVendor = computed(() => { + const groups = new Map() + for (const model of chatbotModelChoices()) { + const list = groups.get(model.vendor) ?? [] + list.push(model) + groups.set(model.vendor, list) + } + return [...groups.entries()] +}) + +// Correct stale/default selections as soon as the billing tier arrives. The +// server enforces the same policy, so this is presentation rather than trust. +watchEffect(() => { + if (!isFree.value || !platformAvailable.value) return + selectedAiConfigId.value = PLATFORM_ENGINE_ID + selectedModel.value = FREE_PLAN_CHATBOT_MODEL_ID +}) + const label = computed(() => { + if (selectedModelChoice.value) return selectedModelChoice.value.label + if (platformEngineActive.value) return 'Reqcore AI' if (selectedConfig.value) return selectedConfig.value.name if (defaultChatbotConfig.value) return `Default · ${defaultChatbotConfig.value.name}` return 'Default model' }) -async function pick(id: string | null) { - selectedAiConfigId.value = id +function formatMultiplier(multiplier: number): string { + // Trim "1.0×" to "1×" but keep "2.4×" — a trailing .0 reads as false precision. + const text = multiplier % 1 === 0 ? String(multiplier) : multiplier.toFixed(1) + return `${text}×` +} + +/** + * Persist the choice so the conversation keeps this engine after a reload. Both + * fields always travel together: a model without its engine, or an engine + * without its model, is how the two drift apart. + */ +async function apply(aiConfigId: string | null, model: string | null) { + const previousAiConfigId = selectedAiConfigId.value + const previousModel = selectedModel.value + selectedAiConfigId.value = aiConfigId + selectedModel.value = model open.value = false - // If we're inside an existing conversation, persist the change so future - // turns keep using this model even after a reload. if (currentConversationId.value) { - await updateConversation(currentConversationId.value, { aiConfigId: id }) + const updated = await updateConversation(currentConversationId.value, { aiConfigId, model }) + if (!updated) { + selectedAiConfigId.value = previousAiConfigId + selectedModel.value = previousModel + } } } +/** + * Star or unstar a model for future chats. This deliberately does not change + * the current conversation: selection and default are separate controls, and + * changing a default must not silently swap the model mid-thread. + */ +async function toggleDefault(modelId: string) { + const clearing = defaultModel.value === modelId + await setDefaultModel(clearing ? null : modelId) +} + function onWindowClick(e: MouseEvent) { if (!root.value) return if (!root.value.contains(e.target as Node)) open.value = false @@ -50,23 +153,33 @@ onUnmounted(() => window.removeEventListener('click', onWindowClick))
-
+ + + + +