Skip to content

fix: Improve application submission and stability - #245

Open
JoachimLK wants to merge 4 commits into
mainfrom
fix/apply-flow-rate-limit-slug-validation
Open

fix: Improve application submission and stability#245
JoachimLK wants to merge 4 commits into
mainfrom
fix/apply-flow-rate-limit-slug-validation

Conversation

@JoachimLK

@JoachimLK JoachimLK commented Jul 26, 2026

Copy link
Copy Markdown
Contributor
  • Add TRUSTED_PROXY environment variable to configure client IP resolution for rate limiting and add documentation for common setups.
  • Add logging for rate limit failures to help diagnose IP resolution issues.
  • Improve job application submission reliability by clamping attribution tracking parameters instead of failing validation.
  • Implement field-level validation errors for public application forms to provide actionable feedback to users.
  • Prevent published job slugs from changing on title edits to avoid breaking existing public links.
  • Add character length constraints to applicant name fields.

Summary

  • What does this PR change?
  • Why is this needed?

PR title must follow Conventional Commits — e.g. feat(jobs): add bulk import or fix: handle null salary. The squash-merged title is what release-please uses to generate the changelog and pick the next version. PRs with non-conventional titles are blocked by CI.

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Chore

Validation

  • I tested locally
  • I added/updated relevant documentation
  • I verified multi-tenant scoping and auth behavior for affected API paths

DCO

  • All commits in this PR are signed off (Signed-off-by) via git commit -s

Summary by CodeRabbit

  • New Features
    • Added configurable reverse-proxy trust for forwarding headers to improve IP-based rate limiting.
    • Improved attribution/tracking parsing to clamp overlong values and safely ignore invalid ones.
  • UI Improvements
    • Added recruiter content typography styles and a dense rendering mode for job/chat markdown.
    • Improved handling of pasted job-description markdown for more consistent list formatting.
  • Bug Fixes
    • Added client-side input length limits and improved inline field errors on application submission.
  • Documentation
    • Expanded self-hosting guidance for reverse proxies and troubleshooting rate limiting.
  • Tests
    • Added/extended unit coverage for slug updates, validation, and rate limiting.

- Add `TRUSTED_PROXY` environment variable to configure client IP
  resolution for rate limiting and add documentation for common setups.
- Add logging for rate limit failures to help diagnose IP resolution
  issues.
- Improve job application submission reliability by clamping attribution
  tracking parameters instead of failing validation.
- Implement field-level validation errors for public application forms
  to provide actionable feedback to users.
- Prevent published job slugs from changing on title edits to avoid
  breaking existing public links.
- Add character length constraints to applicant name fields.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-245 July 26, 2026 13:52 Destroyed
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds configurable trusted-proxy rate limiting, structured application validation, conditional job slug updates, markdown normalization and rendering styles, client-side input limits, documentation, syntax repair, and unit-test coverage.

Changes

Proxy-aware rate limiting

Layer / File(s) Summary
Trusted proxy configuration
.env.example, server/utils/env.ts, SELF-HOSTING.md
Documents and validates TRUSTED_PROXY modes, legacy behavior, and reverse-proxy troubleshooting.
Client key resolution
server/utils/rateLimit.ts
Resolves normalized client keys, records their source, and supports attempt- or success-based accounting.
Proxy strategy coverage
tests/unit/rate-limit.test.ts
Tests proxy modes, fallbacks, normalization, accounting, isolation, and client bucket separation.

Public application validation

Layer / File(s) Summary
Application parsing contract
server/utils/schemas/publicApplication.ts
Normalizes attribution fields, applies field constraints, and returns structured HTTP 400 errors.
Submission integration
server/api/public/jobs/[slug]/apply.post.ts, app/pages/jobs/[slug]/apply.vue
Uses shared parsing, normalizes query attribution, records rate-limit events, and maps field errors into the form.
Input constraints and coverage
app/components/ApplicationFormBody.vue, tests/unit/public-application-validation.test.ts
Adds maxlength attributes and tests attribution and validation behavior.

Conditional job slug updates

Layer / File(s) Summary
Slug contract and integration
shared/job-slug.ts, server/utils/slugify.ts, server/api/jobs/..., app/...
Defines shared slug normalization and conditional updates, with duplicate-conflict retries and dashboard integration.
Slug behavior coverage
tests/unit/job-slug.test.ts
Tests generation, suffix handling, custom overrides, draft retitles, and published slug preservation.

Recruiter markdown rendering

Layer / File(s) Summary
Markdown normalization and rendering
shared/job-description.ts, app/components/MarkdownDescription.vue, app/assets/css/main.css, app/pages/dashboard/chatbot/[[id]].vue
Normalizes pasted markdown and renders job prose with standard and dense typography variants.
Normalization coverage
tests/unit/job-description-normalize.test.ts
Tests list restoration, nesting, fenced code preservation, spacing, and line endings.

Dashboard syntax repair

Layer / File(s) Summary
SFC syntax correction
app/pages/dashboard/jobs/[id]/settings.vue
Restores the valid Vue script setup opening tag.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Applicant
  participant ApplicationForm
  participant ApplicationEndpoint
  participant parsePublicApplication
  Applicant->>ApplicationForm: submit application and tracking values
  ApplicationForm->>ApplicationEndpoint: send multipart or JSON payload
  ApplicationEndpoint->>parsePublicApplication: validate normalized input
  parsePublicApplication-->>ApplicationEndpoint: parsed data or structured 400 error
  ApplicationEndpoint-->>ApplicationForm: success or field-specific error
  ApplicationForm->>Applicant: display inline validation message
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Uses conventional commit format and summarizes the main scope of the PR.
Description check ✅ Passed Includes the required sections and covers the main changes in the summary bullets.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/apply-flow-rate-limit-slug-validation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@JoachimLK JoachimLK changed the title Improve application submission and stability fix: Improve application submission and stability Jul 26, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
server/utils/env.ts (1)

89-98: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider validating TRUSTED_PROXY values in the schema.

The field accepts any string, so TRUSTED_PROXY=true or TRUSTED_PROXY=nginx passes startup validation and only surfaces later as a runtime rate_limit.trusted_proxy_invalid log while silently keeping the production cloudflare default — the exact misconfiguration this PR documents as causing spurious 429s. A schema-level constraint turns it into a boot-time failure.

♻️ Proposed schema constraint
-    TRUSTED_PROXY: emptyToUndefined.optional(),
+    TRUSTED_PROXY: emptyToUndefined
+      .pipe(
+        z.string().regex(
+          /^(cloudflare|none|[1-9]|10)$/i,
+          'TRUSTED_PROXY must be "cloudflare", "none", or a hop count 1-10',
+        ),
+      )
+      .optional(),

Note that server/utils/rateLimit.ts intentionally reads process.env directly, so this only adds a startup guard; it does not change resolution behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/env.ts` around lines 89 - 98, Update the TRUSTED_PROXY schema in
the environment configuration to accept only the documented values: cloudflare,
none, or a numeric hop count from 1 through 10. Preserve the existing optional
and empty-to-undefined behavior, while rejecting arbitrary strings such as true
or nginx during startup; leave TRUSTED_PROXY_IP and rateLimit resolution
unchanged.
tests/unit/rate-limit.test.ts (2)

38-45: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard the NODE_ENV restore against an originally-unset value.

process.env.NODE_ENV = undefined stringifies to "undefined" rather than deleting the key, leaking a bogus value to other tests in the worker. Harmless for the === 'production' checks here, but cheap to make correct.

♻️ Proposed tweak
+function restoreNodeEnv() {
+  if (ORIGINAL_ENV.NODE_ENV === undefined) delete process.env.NODE_ENV
+  else process.env.NODE_ENV = ORIGINAL_ENV.NODE_ENV
+}
+
 beforeEach(() => {
   delete process.env.TRUSTED_PROXY
   delete process.env.TRUSTED_PROXY_IP
-  process.env.NODE_ENV = ORIGINAL_ENV.NODE_ENV
+  restoreNodeEnv()
 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/rate-limit.test.ts` around lines 38 - 45, Update the NODE_ENV
restoration in beforeEach and afterEach to delete process.env.NODE_ENV when
ORIGINAL_ENV.NODE_ENV was initially unset; otherwise restore its original value.
Preserve restoration of an originally defined NODE_ENV and the existing proxy
cleanup behavior.

218-225: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case where X-Forwarded-For has fewer entries than the configured hop count.

That's the branch where forwardedIpFromRight clamps to index 0 and returns a client-written value — see the comment on server/utils/rateLimit.ts Lines 295-301. A test with TRUSTED_PROXY=2 and a two-entry header where the leftmost is attacker-supplied would pin the intended behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/rate-limit.test.ts` around lines 218 - 225, Add a unit test
alongside the existing X-Forwarded-For hop-count case that sets TRUSTED_PROXY to
2 and supplies a two-entry header with an attacker-controlled leftmost value,
asserting getClientIp(makeEvent(...)) returns that leftmost value when
forwardedIpFromRight clamps to index 0.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/pages/jobs/`[slug]/apply.vue:
- Around line 258-262: Update the field-level error mapping near the `field`
lookup so server responses naming `coverLetterText` are assigned to
`errors.coverLetter`, while retaining the existing `form.value` mapping for
other fields. Ensure the mapped cover-letter error renders inline rather than
remaining global.

In `@server/utils/rateLimit.ts`:
- Around line 191-205: Prevent invalid explicit TRUSTED_PROXY values from
falling through to the production Cloudflare default. In
server/utils/rateLimit.ts lines 191-205, make the invalid-value path fatal after
logging rate_limit.trusted_proxy_invalid, or explicitly document the intentional
fail-open behavior; in server/utils/env.ts lines 89-98, constrain TRUSTED_PROXY
validation to cloudflare, none, or numeric hop counts 1-10 so invalid
configuration fails during startup.
- Around line 113-122: Update the rate-limit logging in the exceeded-limit path
around resolveClientKey to avoid emitting the raw client IP: apply the
repository’s established privacy-preserving hashing or truncation helper to ip
before assigning client_key, while preserving client_key_source and the
remaining diagnostic fields.
- Around line 295-301: Update forwardedIpFromRight to return undefined when hops
exceeds entries.length instead of clamping the index to zero. Only normalize and
return the selected forwarded entry when the configured hop count is available,
preserving the empty-entry behavior so callers fall back to the socket key and
warning.

In `@server/utils/slugify.ts`:
- Around line 53-54: Update the draft branch in generateJobSlug to regenerate
only when newTitle differs from currentTitle, preserving an existing custom slug
when the submitted title is unchanged. Add a regression test covering an
unchanged draft title without customSlug and verify the custom slug remains
intact.

---

Nitpick comments:
In `@server/utils/env.ts`:
- Around line 89-98: Update the TRUSTED_PROXY schema in the environment
configuration to accept only the documented values: cloudflare, none, or a
numeric hop count from 1 through 10. Preserve the existing optional and
empty-to-undefined behavior, while rejecting arbitrary strings such as true or
nginx during startup; leave TRUSTED_PROXY_IP and rateLimit resolution unchanged.

In `@tests/unit/rate-limit.test.ts`:
- Around line 38-45: Update the NODE_ENV restoration in beforeEach and afterEach
to delete process.env.NODE_ENV when ORIGINAL_ENV.NODE_ENV was initially unset;
otherwise restore its original value. Preserve restoration of an originally
defined NODE_ENV and the existing proxy cleanup behavior.
- Around line 218-225: Add a unit test alongside the existing X-Forwarded-For
hop-count case that sets TRUSTED_PROXY to 2 and supplies a two-entry header with
an attacker-controlled leftmost value, asserting getClientIp(makeEvent(...))
returns that leftmost value when forwardedIpFromRight clamps to index 0.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: af161633-f8f1-4341-87fd-b049f3f54b3b

📥 Commits

Reviewing files that changed from the base of the PR and between 3b051c6 and ef467cd.

📒 Files selected for processing (13)
  • .env.example
  • SELF-HOSTING.md
  • app/components/ApplicationFormBody.vue
  • app/pages/jobs/[slug]/apply.vue
  • server/api/jobs/[id].patch.ts
  • server/api/public/jobs/[slug]/apply.post.ts
  • server/utils/env.ts
  • server/utils/rateLimit.ts
  • server/utils/schemas/publicApplication.ts
  • server/utils/slugify.ts
  • tests/unit/job-slug.test.ts
  • tests/unit/public-application-validation.test.ts
  • tests/unit/rate-limit.test.ts

Comment on lines +258 to +262
// Field-level validation failures name their field — show it inline too
const field = err.data?.data?.field
if (status === 400 && typeof field === 'string' && field in form.value) {
errors.value[field] = message
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Map coverLetterText to the displayed cover-letter error key.

The server returns field: "coverLetterText", but this branch only accepts keys in form; cover letter state is separate and local validation uses errors.coverLetter. Server-side cover-letter validation errors therefore remain global instead of rendering beside the field.

Proposed fix
-    const field = err.data?.data?.field
-    if (status === 400 && typeof field === 'string' && field in form.value) {
+    const serverField = err.data?.data?.field
+    const field = serverField === 'coverLetterText' ? 'coverLetter' : serverField
+    if (status === 400 && typeof field === 'string' && (field in form.value || field === 'coverLetter')) {
       errors.value[field] = message
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Field-level validation failures name their field — show it inline too
const field = err.data?.data?.field
if (status === 400 && typeof field === 'string' && field in form.value) {
errors.value[field] = message
}
// Field-level validation failures name their field — show it inline too
const serverField = err.data?.data?.field
const field = serverField === 'coverLetterText' ? 'coverLetter' : serverField
if (status === 400 && typeof field === 'string' && (field in form.value || field === 'coverLetter')) {
errors.value[field] = message
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/pages/jobs/`[slug]/apply.vue around lines 258 - 262, Update the
field-level error mapping near the `field` lookup so server responses naming
`coverLetterText` are assigned to `errors.coverLetter`, while retaining the
existing `form.value` mapping for other fields. Ensure the mapped cover-letter
error renders inline rather than remaining global.

Comment thread server/utils/rateLimit.ts
Comment on lines +113 to +122
// Log the resolved bucket key: a limit that trips for unrelated clients is
// almost always an IP-resolution problem (see resolveClientKey), and that
// is invisible without knowing which key was counted.
logWarn('rate_limit.exceeded', {
client_key: ip,
client_key_source: source,
path: getRequestURL(event).pathname,
limit: maxRequests,
window_ms: windowMs,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Client IP is now written to logs — confirm this fits your retention/redaction posture.

client_key is a raw client IP (personal data under GDPR). Rate-limit abuse diagnostics is a defensible legitimate interest, but this repo already ships GDPR retention machinery, so the log sink should have a bounded retention window — or hash/truncate the key here if logs are long-lived.

🛡️ Optional: log a truncated key instead
       logWarn('rate_limit.exceeded', {
-        client_key: ip,
+        client_key: maskIp(ip),
         client_key_source: source,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/rateLimit.ts` around lines 113 - 122, Update the rate-limit
logging in the exceeded-limit path around resolveClientKey to avoid emitting the
raw client IP: apply the repository’s established privacy-preserving hashing or
truncation helper to ip before assigning client_key, while preserving
client_key_source and the remaining diagnostic fields.

Comment thread server/utils/rateLimit.ts
Comment on lines +191 to +205
logWarn('rate_limit.trusted_proxy_invalid', {
value: trustedProxy,
hint: `Expected "cloudflare", "none", or a hop count 1-${MAX_TRUSTED_HOPS}`,
})
}

// Default: use the socket remote address (cannot be spoofed)
return getRequestIP(event) ?? '0.0.0.0'
if (trustedPeer) return { kind: 'trusted-peer', ip: trustedPeer }

// Hosted reqcore serves every request through Cloudflare → Railway, so the
// socket peer is the platform edge and is identical for all traffic. Keying on
// it would put the entire internet in one bucket — the 6th applicant across
// all orgs would get a 429. Default to the Cloudflare header in production;
// deployments exposed directly to the internet must set TRUSTED_PROXY=none,
// otherwise a client can pick its own bucket by sending CF-Connecting-IP.
if (process.env.NODE_ENV === 'production') return { kind: 'cloudflare' }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Unvalidated TRUSTED_PROXY values fail open to cloudflare in production. The root cause is that no layer rejects a bad value: the env schema accepts any string, and the runtime parser warns and then falls through to the production default, which trusts CF-Connecting-IP. On a directly-exposed host where the operator intended none, a typo lets clients choose their own rate-limit bucket.

  • server/utils/rateLimit.ts#L191-L205: after logging rate_limit.trusted_proxy_invalid, don't fall through to the production cloudflare default — either document the fail-open decision explicitly in the comment block or treat an invalid explicit value as fatal.
  • server/utils/env.ts#L89-L98: constrain TRUSTED_PROXY to cloudflare | none | 1-10 so a bad value fails at startup instead of degrading silently at request time.
📍 Affects 2 files
  • server/utils/rateLimit.ts#L191-L205 (this comment)
  • server/utils/env.ts#L89-L98
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/rateLimit.ts` around lines 191 - 205, Prevent invalid explicit
TRUSTED_PROXY values from falling through to the production Cloudflare default.
In server/utils/rateLimit.ts lines 191-205, make the invalid-value path fatal
after logging rate_limit.trusted_proxy_invalid, or explicitly document the
intentional fail-open behavior; in server/utils/env.ts lines 89-98, constrain
TRUSTED_PROXY validation to cloudflare, none, or numeric hop counts 1-10 so
invalid configuration fails during startup.

Comment thread server/utils/rateLimit.ts
Comment on lines +295 to +301
function forwardedIpFromRight(event: H3Event, hops: number): string | undefined {
const entries = forwardedEntries(event)
if (entries.length === 0) return undefined

const index = Math.max(0, entries.length - hops)
return normalizeIp(entries[index])
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Clamping the hop index to 0 trusts a client-supplied X-Forwarded-For entry.

When entries.length < hops, Math.max(0, …) silently selects the leftmost entry. With TRUSTED_PROXY=2 but only one proxy actually appending, a client sending X-Forwarded-For: <chosen-ip> produces two entries and index 0 resolves to the value the client chose — self-selected rate-limit bucket. Returning undefined instead falls through to the socket key and emits the existing rate_limit.socket_ip_fallback warning, which also surfaces the misconfiguration.

🛡️ Proposed fix
 function forwardedIpFromRight(event: H3Event, hops: number): string | undefined {
   const entries = forwardedEntries(event)
-  if (entries.length === 0) return undefined
-
-  const index = Math.max(0, entries.length - hops)
-  return normalizeIp(entries[index])
+  // Fewer entries than trusted hops means the chain is shorter than configured;
+  // the leftmost entry would then be client-written, so refuse it.
+  if (entries.length < hops) return undefined
+
+  return normalizeIp(entries[entries.length - hops])
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function forwardedIpFromRight(event: H3Event, hops: number): string | undefined {
const entries = forwardedEntries(event)
if (entries.length === 0) return undefined
const index = Math.max(0, entries.length - hops)
return normalizeIp(entries[index])
}
function forwardedIpFromRight(event: H3Event, hops: number): string | undefined {
const entries = forwardedEntries(event)
// Fewer entries than trusted hops means the chain is shorter than configured;
// the leftmost entry would then be client-written, so refuse it.
if (entries.length < hops) return undefined
return normalizeIp(entries[entries.length - hops])
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/rateLimit.ts` around lines 295 - 301, Update
forwardedIpFromRight to return undefined when hops exceeds entries.length
instead of clamping the index to zero. Only normalize and return the selected
forwarded entry when the configured hop count is available, preserving the
empty-entry behavior so callers fall back to the socket key and warning.

Comment thread server/utils/slugify.ts Outdated
Comment on lines +53 to +54
if (newTitle && currentStatus === 'draft') {
return generateJobSlug(newTitle, id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Avoid regenerating a draft slug for an unchanged title.

newTitle is not compared with currentTitle. A draft that already has a custom slug will lose it when an edit submits the same title without customSlug. Only regenerate when the title actually changed; add a same-title regression test.

Proposed fix
-  if (newTitle && currentStatus === 'draft') {
+  if (newTitle && newTitle !== currentTitle && currentStatus === 'draft') {
     return generateJobSlug(newTitle, id)
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (newTitle && currentStatus === 'draft') {
return generateJobSlug(newTitle, id)
if (newTitle && newTitle !== currentTitle && currentStatus === 'draft') {
return generateJobSlug(newTitle, id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/slugify.ts` around lines 53 - 54, Update the draft branch in
generateJobSlug to regenerate only when newTitle differs from currentTitle,
preserving an existing custom slug when the submitted title is unchanged. Add a
regression test covering an unchanged draft title without customSlug and verify
the custom slug remains intact.

@railway-app

railway-app Bot commented Jul 26, 2026

Copy link
Copy Markdown

🚅 Deployed to the reqcore-pr-245 environment in applirank

Service Status Web Updated (UTC)
applirank ✅ Success (View Logs) Jul 29, 2026 at 1:23 pm

@coderabbitai coderabbitai Bot mentioned this pull request Jul 27, 2026
9 tasks
…imit-slug-validation

# Conflicts:
#	server/api/jobs/[id].patch.ts
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-245 July 29, 2026 05:49 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/rate-limit.test.ts (1)

240-243: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

This test passes for the wrong reason — the risky path is uncovered.

NODE_ENV here is the test default, so an invalid TRUSTED_PROXY yields socket via the non-production default. Under NODE_ENV=production the same input falls through to cloudflare and does trust headers. Add that case (plus a hops > XFF-entry-count case) so the fail-open behavior flagged in server/utils/rateLimit.ts is pinned by a test.

💚 Suggested additional cases
   it('ignores an invalid TRUSTED_PROXY value instead of trusting headers blindly', () => {
     process.env.TRUSTED_PROXY = 'yes-please'
     expect(getClientIp(makeEvent('100.64.0.3', { 'cf-connecting-ip': '203.0.113.7' }))).toBe('100.64.0.3')
   })
+
+  it('does not trust CF-Connecting-IP in production when TRUSTED_PROXY is invalid', () => {
+    process.env.NODE_ENV = 'production'
+    process.env.TRUSTED_PROXY = 'yes-please'
+    expect(getClientIp(makeEvent('100.64.0.3', { 'cf-connecting-ip': '203.0.113.7' }))).toBe('100.64.0.3')
+  })
+
+  it('ignores X-Forwarded-For when the chain is shorter than the trusted hop count', () => {
+    process.env.TRUSTED_PROXY = '2'
+    expect(getClientIp(makeEvent('10.0.0.1', { 'x-forwarded-for': '1.1.1.1' }))).toBe('10.0.0.1')
+  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/rate-limit.test.ts` around lines 240 - 243, Extend the rate-limit
tests around the invalid TRUSTED_PROXY case to run with NODE_ENV set to
production and assert that invalid configuration does not select the
cloudflare/header-trusting path, then restore the environment afterward. Add
coverage for a configured hops value exceeding the available X-Forwarded-For
entries and assert the client IP remains safe; keep the existing valid
configuration tests unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/pages/jobs/`[slug]/apply.vue:
- Around line 18-29: Update firstQueryValue to trim candidate string values
before validating them, treating whitespace-only entries as unusable and
continuing through arrays to find the first non-empty trimmed value. Return the
trimmed value so the source tracking parameters match the schema’s trimmed-value
behavior.

In `@server/utils/schemas/publicApplication.ts`:
- Around line 43-44: Update the firstName and lastName validators in the public
application schema to trim surrounding whitespace before applying the required
and maximum-length checks, ensuring whitespace-only JSON values fail validation
while preserving existing error messages and limits.

---

Nitpick comments:
In `@tests/unit/rate-limit.test.ts`:
- Around line 240-243: Extend the rate-limit tests around the invalid
TRUSTED_PROXY case to run with NODE_ENV set to production and assert that
invalid configuration does not select the cloudflare/header-trusting path, then
restore the environment afterward. Add coverage for a configured hops value
exceeding the available X-Forwarded-For entries and assert the client IP remains
safe; keep the existing valid configuration tests unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b9983d38-a88e-4461-ab4c-ef8cf6386288

📥 Commits

Reviewing files that changed from the base of the PR and between 8539161 and 33c3736.

📒 Files selected for processing (13)
  • .env.example
  • SELF-HOSTING.md
  • app/components/ApplicationFormBody.vue
  • app/pages/jobs/[slug]/apply.vue
  • server/api/jobs/[id].patch.ts
  • server/api/public/jobs/[slug]/apply.post.ts
  • server/utils/env.ts
  • server/utils/rateLimit.ts
  • server/utils/schemas/publicApplication.ts
  • server/utils/slugify.ts
  • tests/unit/job-slug.test.ts
  • tests/unit/public-application-validation.test.ts
  • tests/unit/rate-limit.test.ts

Comment on lines +18 to +29
function firstQueryValue(value: unknown): string | undefined {
const raw = Array.isArray(value) ? value.find((v) => typeof v === 'string' && v.length > 0) : value
return typeof raw === 'string' && raw.length > 0 ? raw : undefined
}

// Capture source tracking params from the URL
const sourceRef = (route.query.ref as string) || undefined
const utmSource = (route.query.utm_source as string) || undefined
const utmMedium = (route.query.utm_medium as string) || undefined
const utmCampaign = (route.query.utm_campaign as string) || undefined
const utmTerm = (route.query.utm_term as string) || undefined
const utmContent = (route.query.utm_content as string) || undefined
const sourceRef = firstQueryValue(route.query.ref)
const utmSource = firstQueryValue(route.query.utm_source)
const utmMedium = firstQueryValue(route.query.utm_medium)
const utmCampaign = firstQueryValue(route.query.utm_campaign)
const utmTerm = firstQueryValue(route.query.utm_term)
const utmContent = firstQueryValue(route.query.utm_content)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Treat whitespace-only query values as unusable.

Line 19 selects ' ' as the first value, then the server drops it and never sees a later usable value such as ?ref=%20%20%20&ref=linkedin. Match the schema’s trimmed-value behavior here.

Proposed fix
 function firstQueryValue(value: unknown): string | undefined {
-  const raw = Array.isArray(value) ? value.find((v) => typeof v === 'string' && v.length > 0) : value
-  return typeof raw === 'string' && raw.length > 0 ? raw : undefined
+  const raw = Array.isArray(value)
+    ? value.find((v) => typeof v === 'string' && v.trim().length > 0)
+    : value
+  return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function firstQueryValue(value: unknown): string | undefined {
const raw = Array.isArray(value) ? value.find((v) => typeof v === 'string' && v.length > 0) : value
return typeof raw === 'string' && raw.length > 0 ? raw : undefined
}
// Capture source tracking params from the URL
const sourceRef = (route.query.ref as string) || undefined
const utmSource = (route.query.utm_source as string) || undefined
const utmMedium = (route.query.utm_medium as string) || undefined
const utmCampaign = (route.query.utm_campaign as string) || undefined
const utmTerm = (route.query.utm_term as string) || undefined
const utmContent = (route.query.utm_content as string) || undefined
const sourceRef = firstQueryValue(route.query.ref)
const utmSource = firstQueryValue(route.query.utm_source)
const utmMedium = firstQueryValue(route.query.utm_medium)
const utmCampaign = firstQueryValue(route.query.utm_campaign)
const utmTerm = firstQueryValue(route.query.utm_term)
const utmContent = firstQueryValue(route.query.utm_content)
function firstQueryValue(value: unknown): string | undefined {
const raw = Array.isArray(value)
? value.find((v) => typeof v === 'string' && v.trim().length > 0)
: value
return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/pages/jobs/`[slug]/apply.vue around lines 18 - 29, Update firstQueryValue
to trim candidate string values before validating them, treating whitespace-only
entries as unusable and continuing through arrays to find the first non-empty
trimmed value. Return the trimmed value so the source tracking parameters match
the schema’s trimmed-value behavior.

Comment on lines +43 to +44
firstName: z.string().min(1, 'First name is required').max(100, 'Must be 100 characters or fewer'),
lastName: z.string().min(1, 'Last name is required').max(100, 'Must be 100 characters or fewer'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Trim required names before validating them.

Line 43 and Line 44 accept whitespace-only JSON values because .min(1) counts spaces. Multipart input is trimmed upstream, but JSON is passed directly to this schema, so the two submission paths can persist blank applicant names.

Proposed fix
-  firstName: z.string().min(1, 'First name is required').max(100, 'Must be 100 characters or fewer'),
-  lastName: z.string().min(1, 'Last name is required').max(100, 'Must be 100 characters or fewer'),
+  firstName: z.string().trim().min(1, 'First name is required').max(100, 'Must be 100 characters or fewer'),
+  lastName: z.string().trim().min(1, 'Last name is required').max(100, 'Must be 100 characters or fewer'),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
firstName: z.string().min(1, 'First name is required').max(100, 'Must be 100 characters or fewer'),
lastName: z.string().min(1, 'Last name is required').max(100, 'Must be 100 characters or fewer'),
firstName: z.string().trim().min(1, 'First name is required').max(100, 'Must be 100 characters or fewer'),
lastName: z.string().trim().min(1, 'Last name is required').max(100, 'Must be 100 characters or fewer'),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/schemas/publicApplication.ts` around lines 43 - 44, Update the
firstName and lastName validators in the public application schema to trim
surrounding whitespace before applying the required and maximum-length checks,
ensuring whitespace-only JSON values fail validation while preserving existing
error messages and limits.

- Extract slug logic into shared `job-slug.ts` to unify base generation,
  suffix stripping, and preview formatting across the stack.
- Improve slug generation to support international titles.
- Add collision handling for job creation and updates by retrying with a
  fresh suffix if a conflict occurs.
- Update `createRateLimiter` to support a `successes` count mode,
  ensuring
  rejected form submissions do not consume an applicant's rate limit.
@railway-app
railway-app Bot temporarily deployed to applirank / reqcore-pr-245 July 29, 2026 06:21 Destroyed
Implement `job-prose` styles for rendering markdown content, replacing
`@tailwindcss/typography` to ensure consistent rendering of
recruiter-pasted
content. Added `normalizeDescriptionMarkdown` to sanitize pasted text
(fixing indent-to-code-block issues) and a `dense` mode for chat
contexts.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/utils/rateLimit.ts (1)

175-202: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Reserve capacity for in-flight successful requests.

In successes mode, concurrent requests all pass Line 175 before any handler reaches record() at Line 198. Since the public application endpoint uses this mode, a parallel burst can exceed maxRequests by an arbitrary amount. Track pending reservations during enforcement and release them when submission fails, or make successful accounting atomic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/rateLimit.ts` around lines 175 - 202, Update the rate-limit
enforcement around the returned `RateLimiter` and its `record` method so
`successes` mode reserves capacity before allowing a request to proceed,
preventing concurrent requests from all passing the limit check. Track pending
reservations per resolved client key, include them in the limit calculation, and
release a reservation when submission fails while converting it to successful
accounting in `record`; preserve the existing `attempts` behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/pages/dashboard/jobs/`[id]/settings.vue:
- Line 1: Remove the stray “conca” prefix before the script setup block in the
component, leaving the opening tag exactly as `<script setup lang="ts">` so the
Vue SFC parses correctly.

In `@server/api/public/jobs/`[slug]/apply.post.ts:
- Around line 32-35: Make the success-only admission flow atomic: update
createRateLimiter and rateLimit so concurrent requests reserve capacity before
processing, then commit the reservation only on successful submission and
release it on validation or other failure paths; ensure the combined recorded
and in-flight count never exceeds maxRequests. Update both deferred record()
call sites near the success paths to use this reservation lifecycle without
counting validation failures.

In `@server/utils/slugify.ts`:
- Around line 47-68: The draft branch in resolveJobSlugUpdate must only
regenerate a slug when newTitle differs from currentTitle; add that comparison
while preserving the publishedAt check. In tests/unit/job-slug.test.ts lines
75-145, add a regression case asserting undefined for an unpublished job with
identical currentTitle and newTitle and an existing custom slug.

---

Outside diff comments:
In `@server/utils/rateLimit.ts`:
- Around line 175-202: Update the rate-limit enforcement around the returned
`RateLimiter` and its `record` method so `successes` mode reserves capacity
before allowing a request to proceed, preventing concurrent requests from all
passing the limit check. Track pending reservations per resolved client key,
include them in the limit calculation, and release a reservation when submission
fails while converting it to successful accounting in `record`; preserve the
existing `attempts` behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 451dbf88-e597-4810-9b76-4537db7e1850

📥 Commits

Reviewing files that changed from the base of the PR and between 33c3736 and 490956d.

📒 Files selected for processing (16)
  • app/assets/css/main.css
  • app/components/MarkdownDescription.vue
  • app/composables/useJob.ts
  • app/pages/dashboard/chatbot/[[id]].vue
  • app/pages/dashboard/jobs/[id]/settings.vue
  • app/pages/dashboard/jobs/new.vue
  • server/api/jobs/[id].patch.ts
  • server/api/jobs/index.post.ts
  • server/api/public/jobs/[slug]/apply.post.ts
  • server/utils/rateLimit.ts
  • server/utils/slugify.ts
  • shared/job-description.ts
  • shared/job-slug.ts
  • tests/unit/job-description-normalize.test.ts
  • tests/unit/job-slug.test.ts
  • tests/unit/rate-limit.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/unit/rate-limit.test.ts

@@ -1,4 +1,4 @@
<script setup lang="ts">
conca<script setup lang="ts">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="app/pages/dashboard/jobs/[id]/settings.vue"

if [ -f "$file" ]; then
  echo "== first 10 lines =="
  cat -n "$file" | sed -n '1,10p'
else
  echo "File not found: $file"
  echo "Matching settings.vue files:"
  fd -a 'settings\.vue$' . | sed "s#$(pwd)/##" | head -50
fi

echo "== search for malformed conca/script tag-like syntax in tracked files =="
git ls-files | xargs rg -n '<script\s|conca|<script' --glob '*.vue' --glob '*.ts' --glob '*.js' || true

Repository: reqcore-inc/reqcore

Length of output: 12688


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
text = Path("app/pages/dashboard/jobs/[id]/settings.vue").read_text()
first_line = text.splitlines()[0]
script_tag = "<script setup lang=\"ts\">"
print(f"first_line_matches='<script setup lang=\"ts\">': {first_line == script_tag}")
print(f"starts_with_conca_prefix: {first_line.startswith('conca')}")
print(f"contains_expected_script_tag_after_conca: {'<script setup lang=\"ts\">' in first_line}")
PY

Repository: reqcore-inc/reqcore

Length of output: 449


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
s = Path("app/pages/dashboard/jobs/[id]/settings.vue").read_text()
first_line = s.splitlines()[0]
expected = '<script setup lang="ts">'
print(f"first_line_matches=<script setup lang='ts'>: {first_line == expected}")
print(f"starts_with_conca_prefix: {first_line.startswith('conca')}")
print(f"contains_expected_script_tag_after_conca: {expected in first_line}")
PY

Repository: reqcore-inc/reqcore

Length of output: 286


Remove the stray conca prefix.

app/pages/dashboard/jobs/[id]/settings.vue:1 currently reads conca<script setup lang="ts">, which is invalid Vue SFC syntax and will prevent the component from compiling. It should be exactly <script setup lang="ts">.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/pages/dashboard/jobs/`[id]/settings.vue at line 1, Remove the stray
“conca” prefix before the script setup block in the component, leaving the
opening tag exactly as `<script setup lang="ts">` so the Vue SFC parses
correctly.

Comment on lines 32 to +35
const applyRateLimit = createRateLimiter({
windowMs: 15 * 60 * 1000,
maxRequests: 5,
windowMs: 60 * 60 * 1000,
maxRequests: 30,
countMode: 'successes',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Make success-only rate-limit admission atomic.

With countMode: 'successes', rateLimit(event) only checks the current bucket; recording is deferred until Line 192 or Line 736. If 29 of 30 slots are used, concurrent submissions can all pass before any record() call, then push the bucket beyond 30 because record() does not re-check the limit.

Add an atomic in-flight reservation/commit-release API to the limiter, or use attempt counting if a strict cap is required. Moving record() earlier would incorrectly count validation failures.

Also applies to: 187-192, 733-737

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/public/jobs/`[slug]/apply.post.ts around lines 32 - 35, Make the
success-only admission flow atomic: update createRateLimiter and rateLimit so
concurrent requests reserve capacity before processing, then commit the
reservation only on successful submission and release it on validation or other
failure paths; ensure the combined recorded and in-flight count never exceeds
maxRequests. Update both deferred record() call sites near the success paths to
use this reservation lifecycle without counting validation failures.

Comment thread server/utils/slugify.ts
Comment on lines +47 to +68
export function resolveJobSlugUpdate(args: {
id: string
publishedAt: Date | null
currentTitle: string
newTitle?: string
customSlug?: string
}): string | undefined {
const { id, publishedAt, currentTitle, newTitle, customSlug } = args

// An explicit slug is the recruiter asking for the rename on purpose. Callers
// must only send one the recruiter actually changed — sending back whatever
// was loaded into the field reads as a deliberate rename on every save.
if (customSlug?.trim()) {
return generateJobSlug(newTitle ?? currentTitle, id, customSlug)
}

if (newTitle && publishedAt === null) {
return generateJobSlug(newTitle, id)
}

return undefined
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Missing newTitle !== currentTitle guard, and no regression test for it. resolveJobSlugUpdate's draft branch (newTitle && publishedAt === null) regenerates the slug from the title with no comparison to currentTitle, so resaving a never-published job with its title unchanged silently overwrites a recruiter-set custom slug. The test suite doesn't exercise this case either.

  • server/utils/slugify.ts#L47-L68: add newTitle !== currentTitle to the draft-branch condition so an unchanged title never regenerates the slug.
  • tests/unit/job-slug.test.ts#L75-L145: add a case asserting resolveJobSlugUpdate returns undefined when publishedAt: null, newTitle === currentTitle, and a customSlug was previously set (i.e., the stored slug isn't title-derived).
📍 Affects 2 files
  • server/utils/slugify.ts#L47-L68 (this comment)
  • tests/unit/job-slug.test.ts#L75-L145
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/slugify.ts` around lines 47 - 68, The draft branch in
resolveJobSlugUpdate must only regenerate a slug when newTitle differs from
currentTitle; add that comparison while preserving the publishedAt check. In
tests/unit/job-slug.test.ts lines 75-145, add a regression case asserting
undefined for an unpublished job with identical currentTitle and newTitle and an
existing custom slug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant