Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 4 additions & 13 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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_<UPPERCASE_KEY_WITH_UNDERSCORES>
# 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.
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions DATA-RETENTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 12 additions & 3 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ const i18nHead = useLocaleHead({
seo: true,
})

// @nuxtjs/i18n exposes these as Record<string, string> 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).
Expand All @@ -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
Expand Down
33 changes: 5 additions & 28 deletions app/components/AppTopBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand All @@ -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' },
Expand All @@ -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, () => {
Expand Down
Loading
Loading