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
2 changes: 1 addition & 1 deletion app/layouts/dashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const isDemo = computed(() => {
>
<Eye class="size-4 shrink-0" />
<span>
<strong>Demo mode</strong> — You're exploring with sample data. Changes are disabled.
<strong>Preview mode</strong> — You're exploring with sample data in read-only mode. Changes are disabled.
<a
href="https://github.com/applirank/applirank"
target="_blank"
Expand Down
34 changes: 17 additions & 17 deletions app/pages/dashboard/candidates/[id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -301,14 +301,14 @@ function formatFileSize(bytes: number | null | undefined): string {

<div class="flex items-center gap-2 shrink-0">
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-surface-300 px-3 py-1.5 text-sm font-medium text-surface-700 hover:bg-surface-50 transition-colors"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-surface-300 dark:border-surface-700 px-3 py-1.5 text-sm font-medium text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors"
@click="startEdit"
>
<Pencil class="size-3.5" />
Edit
</button>
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-danger-300 px-3 py-1.5 text-sm font-medium text-danger-600 hover:bg-danger-50 transition-colors"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-danger-300 dark:border-danger-700 px-3 py-1.5 text-sm font-medium text-danger-600 dark:text-danger-400 hover:bg-danger-50 dark:hover:bg-danger-950 transition-colors"
@click="showDeleteConfirm = true"
>
<Trash2 class="size-3.5" />
Expand Down Expand Up @@ -630,59 +630,59 @@ function formatFileSize(bytes: number | null | undefined): string {
<form class="space-y-5" @submit.prevent="handleSave">
<!-- First Name -->
<div>
<label for="edit-firstName" class="block text-sm font-medium text-surface-700 mb-1">
<label for="edit-firstName" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
First Name <span class="text-danger-500">*</span>
</label>
<input
id="edit-firstName"
v-model="editForm.firstName"
type="text"
class="w-full rounded-lg border px-3 py-2 text-sm text-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
:class="editErrors.firstName ? 'border-danger-300' : 'border-surface-300'"
class="w-full rounded-lg border px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
:class="editErrors.firstName ? 'border-danger-300' : 'border-surface-300 dark:border-surface-700'"
/>
<p v-if="editErrors.firstName" class="mt-1 text-xs text-danger-600">{{ editErrors.firstName }}</p>
<p v-if="editErrors.firstName" class="mt-1 text-xs text-danger-600 dark:text-danger-400">{{ editErrors.firstName }}</p>
</div>

<!-- Last Name -->
<div>
<label for="edit-lastName" class="block text-sm font-medium text-surface-700 mb-1">
<label for="edit-lastName" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
Last Name <span class="text-danger-500">*</span>
</label>
<input
id="edit-lastName"
v-model="editForm.lastName"
type="text"
class="w-full rounded-lg border px-3 py-2 text-sm text-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
:class="editErrors.lastName ? 'border-danger-300' : 'border-surface-300'"
class="w-full rounded-lg border px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
:class="editErrors.lastName ? 'border-danger-300' : 'border-surface-300 dark:border-surface-700'"
/>
<p v-if="editErrors.lastName" class="mt-1 text-xs text-danger-600">{{ editErrors.lastName }}</p>
<p v-if="editErrors.lastName" class="mt-1 text-xs text-danger-600 dark:text-danger-400">{{ editErrors.lastName }}</p>
</div>

<!-- Email -->
<div>
<label for="edit-email" class="block text-sm font-medium text-surface-700 mb-1">
<label for="edit-email" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
Email <span class="text-danger-500">*</span>
</label>
<input
id="edit-email"
v-model="editForm.email"
type="email"
class="w-full rounded-lg border px-3 py-2 text-sm text-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
:class="editErrors.email ? 'border-danger-300' : 'border-surface-300'"
class="w-full rounded-lg border px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
:class="editErrors.email ? 'border-danger-300' : 'border-surface-300 dark:border-surface-700'"
/>
<p v-if="editErrors.email" class="mt-1 text-xs text-danger-600">{{ editErrors.email }}</p>
<p v-if="editErrors.email" class="mt-1 text-xs text-danger-600 dark:text-danger-400">{{ editErrors.email }}</p>
</div>

<!-- Phone -->
<div>
<label for="edit-phone" class="block text-sm font-medium text-surface-700 mb-1">
<label for="edit-phone" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
Phone
</label>
<input
id="edit-phone"
v-model="editForm.phone"
type="tel"
class="w-full rounded-lg border border-surface-300 px-3 py-2 text-sm text-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
class="w-full rounded-lg border border-surface-300 dark:border-surface-700 px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
/>
</div>

Expand All @@ -697,7 +697,7 @@ function formatFileSize(bytes: number | null | undefined): string {
</button>
<button
type="button"
class="rounded-lg border border-surface-300 px-4 py-2 text-sm font-medium text-surface-700 hover:bg-surface-50 transition-colors"
class="rounded-lg border border-surface-300 dark:border-surface-700 px-4 py-2 text-sm font-medium text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors"
@click="cancelEdit"
>
Cancel
Expand Down
28 changes: 26 additions & 2 deletions server/api/auth/[...all].ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
export default defineEventHandler((event) => {
return auth.handler(toWebRequest(event))
export default defineEventHandler(async (event) => {
try {
return await auth.handler(toWebRequest(event))
} catch (error) {
const requestUrl = getRequestURL(event)
console.error('[Applirank] Auth handler error', {
method: event.method,
path: requestUrl.pathname,
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
})

const exposeDetails = isRailwayPreviewEnvironment(env.RAILWAY_ENVIRONMENT_NAME) || import.meta.dev
const details = error instanceof Error ? error.message : 'Unknown error'

throw createError({
statusCode: 500,
statusMessage: exposeDetails ? `Auth handler failed: ${details}` : 'Server Error',
data: exposeDetails
? {
code: 'AUTH_HANDLER_ERROR',
message: details,
}
: undefined,
})
}
})
Comment on lines +1 to 27
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Modifying this file conflicts with the stated policy for server/api/auth/[...all].ts.

The team's own guideline is: "Do not add auth guards to the Better Auth catch-all handler at server/api/auth/[...all].ts and do not modify this file unless changing the auth mount path." This PR adds a try/catch wrapper without changing the auth mount path.

If structured error handling for the auth handler is needed, consider wrapping it in a server plugin or h3 middleware that catches unhandled auth errors globally, rather than touching the catch-all route. Based on learnings, server/api/auth/[...all].ts should only be changed for auth mount path modifications.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/auth/`[...all].ts around lines 1 - 27, You added a try/catch
wrapper around the auth catch-all handler (the defineEventHandler that calls
auth.handler(toWebRequest(event))), which violates the team's guideline to not
modify the catch-all auth route; revert this file to simply return
auth.handler(toWebRequest(event)) (remove the try/catch and any createError
logic) and instead implement any structured error handling in a server plugin or
h3 middleware that globally catches/auth-related errors; use defineEventHandler,
auth.handler, and toWebRequest as the locating symbols when restoring the
original behavior and move logging/creation of errors into the new
middleware/plugin.

16 changes: 15 additions & 1 deletion server/api/public/jobs/[slug]/apply.post.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { eq, and, asc } from 'drizzle-orm'
import { fileTypeFromBuffer } from 'file-type'
import { job, candidate, application, jobQuestion, questionResponse, document } from '../../../../database/schema'
import { job, candidate, application, jobQuestion, questionResponse, document, organization } from '../../../../database/schema'
import { publicApplicationSchema, publicJobSlugSchema } from '../../../../utils/schemas/publicApplication'
import { createPreviewReadOnlyError } from '../../../../utils/previewReadOnly'
import {
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE,
Expand Down Expand Up @@ -140,6 +141,19 @@ export default defineEventHandler(async (event) => {
const orgId = existingJob.organizationId
const jobId = existingJob.id

// Demo org is strictly read-only (defense in depth; middleware also blocks this route)
if (env.DEMO_ORG_SLUG) {
const [demoOrg] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.slug, env.DEMO_ORG_SLUG))
.limit(1)

if (demoOrg?.id === orgId) {
throw createPreviewReadOnlyError()
}
}

// ─────────────────────────────────────────────
// 3. Fetch questions and validate responses
// ─────────────────────────────────────────────
Expand Down
48 changes: 35 additions & 13 deletions server/middleware/demo-guard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { eq } from 'drizzle-orm'
import * as schema from '../database/schema'
import { createPreviewReadOnlyError } from '../utils/previewReadOnly'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove explicit import — createPreviewReadOnlyError is auto-imported from server/utils/.

Nitro auto-imports all utilities from server/utils/, so the explicit import { createPreviewReadOnlyError } from '../utils/previewReadOnly' is unnecessary and inconsistent with how the same file already uses env, auth, and db without imports. As per coding guidelines, "All utilities in server/utils/ are auto-imported by Nitro—do not use explicit imports for db, auth, env, … etc. in server code."

🛠️ Proposed fix
 import { eq } from 'drizzle-orm'
 import * as schema from '../database/schema'
-import { createPreviewReadOnlyError } from '../utils/previewReadOnly'
📝 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
import { createPreviewReadOnlyError } from '../utils/previewReadOnly'
import { eq } from 'drizzle-orm'
import * as schema from '../database/schema'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/middleware/demo-guard.ts` at line 3, Remove the explicit import of
createPreviewReadOnlyError (the line importing from ../utils/previewReadOnly)
and rely on Nitro's auto-imported utilities from server/utils so the middleware
uses createPreviewReadOnlyError the same way it already uses env, auth, and db;
simply delete the import statement referencing createPreviewReadOnlyError to
keep imports consistent and avoid redundant explicit imports.


/**
* Demo guard middleware — blocks write operations (POST, PATCH, PUT, DELETE)
Expand All @@ -14,6 +15,12 @@ import * as schema from '../database/schema'
// ─────────────────────────────────────────────
let demoOrgId: string | null | undefined // undefined = not yet resolved

const PUBLIC_APPLY_PATH_REGEX = /^\/api\/public\/jobs\/([^/]+)\/apply\/?$/

function throwDemoReadOnlyError(): never {
throw createPreviewReadOnlyError()
}

async function getDemoOrgId(): Promise<string | null> {
if (demoOrgId !== undefined) return demoOrgId

Expand All @@ -36,19 +43,40 @@ async function getDemoOrgId(): Promise<string | null> {
const WRITE_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE'])

export default defineEventHandler(async (event) => {
const path = getRequestURL(event).pathname

// Only guard API routes
if (!path.startsWith('/api/')) return

// Always allow auth routes (sign-in, sign-out, session, org switch)
if (path.startsWith('/api/auth/')) return

// Skip if no demo slug configured
if (!env.DEMO_ORG_SLUG) return

// Only guard write operations
if (!WRITE_METHODS.has(event.method)) return

const path = getRequestURL(event).pathname

// Always allow auth routes (sign-in, sign-out, session, org switch)
if (path.startsWith('/api/auth/')) return
const guardedOrgId = await getDemoOrgId()
if (!guardedOrgId) return

// Only guard API routes
if (!path.startsWith('/api/')) return
// Public apply route has no session context, so resolve org by job slug.
const publicApplyMatch = path.match(PUBLIC_APPLY_PATH_REGEX)
if (publicApplyMatch) {
const slug = decodeURIComponent(publicApplyMatch[1] ?? '')
if (!slug) return

const [targetJob] = await db
.select({ organizationId: schema.job.organizationId })
.from(schema.job)
.where(eq(schema.job.slug, slug))
.limit(1)

if (targetJob?.organizationId === guardedOrgId) {
throwDemoReadOnlyError()
}
return
}

// Check if the current session belongs to the demo org
const session = await auth.api.getSession({ headers: event.headers })
Expand All @@ -58,13 +86,7 @@ export default defineEventHandler(async (event) => {

if (!activeOrganizationId) return

const guardedOrgId = await getDemoOrgId()
if (!guardedOrgId) return

if (activeOrganizationId === guardedOrgId) {
throw createError({
statusCode: 403,
statusMessage: 'Demo mode — this action is disabled. Deploy your own instance to get full access.',
})
throwDemoReadOnlyError()
}
})
6 changes: 4 additions & 2 deletions server/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ let _auth: Auth | undefined

function resolveBetterAuthUrl(): string {
const explicitUrl = env.BETTER_AUTH_URL?.trim()
const isPreview = isRailwayPreviewEnvironment(env.RAILWAY_ENVIRONMENT_NAME)
const railwayDomain = env.RAILWAY_PUBLIC_DOMAIN?.trim()
const hasPreviewDomain = railwayDomain ? railwayDomain.toLowerCase().includes('-pr-') : false
const hasPrNumber = !!env.RAILWAY_GIT_PR_NUMBER?.trim()
const isPreview = isRailwayPreviewEnvironment(env.RAILWAY_ENVIRONMENT_NAME) || hasPreviewDomain || hasPrNumber
Comment on lines +11 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

superRefine in env.ts doesn't account for the two new isPreview signals, risking a startup failure.

resolveBetterAuthUrl now treats an environment as preview if hasPrNumber or hasPreviewDomain is true, but env.ts's superRefine still only exempts BETTER_AUTH_URL when isRailwayPreviewEnvironment(RAILWAY_ENVIRONMENT_NAME) returns true. In a Railway deployment where RAILWAY_GIT_PR_NUMBER is set (or RAILWAY_PUBLIC_DOMAIN contains -pr-) but RAILWAY_ENVIRONMENT_NAME is empty or non-preview-like, env.ts validation will throw "BETTER_AUTH_URL is required …" and crash the server before resolveBetterAuthUrl ever runs.

🛠️ Proposed fix — extend `superRefine` to mirror the same three-signal logic
// server/utils/env.ts
  .superRefine((data, ctx) => {
-   const isPreview = isRailwayPreviewEnvironment(data.RAILWAY_ENVIRONMENT_NAME)
+   const hasPreviewDomain = data.RAILWAY_PUBLIC_DOMAIN?.toLowerCase().includes('-pr-') ?? false
+   const hasPrNumber = !!data.RAILWAY_GIT_PR_NUMBER
+   const isPreview =
+     isRailwayPreviewEnvironment(data.RAILWAY_ENVIRONMENT_NAME) ||
+     hasPreviewDomain ||
+     hasPrNumber

    if (!isPreview && !data.BETTER_AUTH_URL) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/auth.ts` around lines 11 - 14, The env.ts superRefine currently
only exempts BETTER_AUTH_URL when
isRailwayPreviewEnvironment(RAILWAY_ENVIRONMENT_NAME) is true; update
superRefine to mirror resolveBetterAuthUrl by computing railwayDomain =
RAILWAY_PUBLIC_DOMAIN?.trim(), hasPreviewDomain = railwayDomain ?
railwayDomain.toLowerCase().includes('-pr-') : false, hasPrNumber =
!!RAILWAY_GIT_PR_NUMBER?.trim(), and set isPreview =
isRailwayPreviewEnvironment(RAILWAY_ENVIRONMENT_NAME) || hasPreviewDomain ||
hasPrNumber so that BETTER_AUTH_URL validation is skipped for any of those three
signals (refer to symbols superRefine, isRailwayPreviewEnvironment,
resolveBetterAuthUrl, BETTER_AUTH_URL, RAILWAY_PUBLIC_DOMAIN,
RAILWAY_GIT_PR_NUMBER, RAILWAY_ENVIRONMENT_NAME).


if (!isPreview) {
if (!explicitUrl) {
Expand All @@ -25,7 +28,6 @@ function resolveBetterAuthUrl(): string {
return previewUrl
}

const railwayDomain = env.RAILWAY_PUBLIC_DOMAIN?.trim()
if (railwayDomain) {
const previewUrl = `https://${railwayDomain}`
console.info(`[Applirank] Using Railway public-domain BETTER_AUTH_URL: ${previewUrl}`)
Expand Down
9 changes: 6 additions & 3 deletions server/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ export function isRailwayPreviewEnvironment(environmentName?: string): boolean {
if (name === 'production' || name === 'prod') return false

return (
name.startsWith('pr')
Comment on lines 25 to +26
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

name.startsWith('pr') is too broad and creates false positives.

Any environment name that starts with "pr" (after lowercasing) — except the explicit "production"/"prod" carve-outs — will now be treated as a preview environment. That includes legitimate long-lived names such as "prod-v2", "proxy", "private", "primary", and "proto". In those environments:

  • env.ts superRefine would skip the BETTER_AUTH_URL requirement.
  • auth.ts would attempt to auto-generate the URL from RAILWAY_GIT_PR_NUMBER/RAILWAY_PUBLIC_DOMAIN, both likely unset, ultimately throwing the opaque "Unable to resolve BETTER_AUTH_URL" error at init time.

The pre-existing /^pr(?:-|\d)/ regex already covers Railway's standard PR environment naming (pr-123, pr123). If the goal is to also catch a bare "pr" environment name, use an exact match instead:

🛠️ Proposed fix — replace startsWith with a precise check
  return (
-   name.startsWith('pr')
-   ||
-   /^pr(?:-|\d)/.test(name)
+   /^pr($|-|\d)/.test(name)
    || name.includes('pr-')
    || name.includes('pr ')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/env.ts` around lines 25 - 26, The check name.startsWith('pr') in
env detection is too broad and causes false positives; update the logic in
server/utils/env.ts (the code that determines preview envs and the superRefine
behavior) to only treat names matching Railway PR patterns or the exact "pr" as
preview — e.g., replace the startsWith check with a regex test like
/^pr(?:-|\d)/i OR an explicit equality check for "pr" so names like "prod-v2",
"proxy", "primary" are not misclassified; ensure the updated condition is used
wherever the preview-environment branch (including the superRefine that skips
BETTER_AUTH_URL) runs.

||
/^pr(?:-|\d)/.test(name)
|| name.includes('pr-')
|| name.includes('pr ')
|| name.includes('pull request')
|| name.includes('pull-request')
|| name.includes('preview')
Expand Down Expand Up @@ -55,11 +58,11 @@ const envSchema = z
/** IP address of the trusted reverse proxy (e.g., Railway, Cloudflare). When set, X-Forwarded-For is trusted for rate limiting. */
TRUSTED_PROXY_IP: z.string().min(1).optional(),
/** Slug of the demo organization. When set, write operations are blocked for this org. */
DEMO_ORG_SLUG: z.string().optional(),
DEMO_ORG_SLUG: emptyToUndefined.optional(),
/** Fine-grained GitHub PAT with Issues:write scope. When set (along with GITHUB_FEEDBACK_REPO), enables in-app feedback. */
GITHUB_FEEDBACK_TOKEN: z.string().min(1).optional(),
GITHUB_FEEDBACK_TOKEN: emptyToUndefined.pipe(z.string().min(1)).optional(),
/** GitHub repo in "owner/repo" format for feedback issues. */
GITHUB_FEEDBACK_REPO: z.string().regex(/^[^/]+\/[^/]+$/, 'Must be in "owner/repo" format').optional(),
GITHUB_FEEDBACK_REPO: emptyToUndefined.pipe(z.string().regex(/^[^/]+\/[^/]+$/, 'Must be in "owner/repo" format')).optional(),
})
.superRefine((data, ctx) => {
const isPreview = isRailwayPreviewEnvironment(data.RAILWAY_ENVIRONMENT_NAME)
Expand Down
12 changes: 12 additions & 0 deletions server/utils/previewReadOnly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const PREVIEW_READ_ONLY_MESSAGE = 'Preview mode — this action is disabled. Deploy your own instance to get full access.'

export function createPreviewReadOnlyError() {
return createError({
statusCode: 403,
statusMessage: PREVIEW_READ_ONLY_MESSAGE,
data: {
code: 'PREVIEW_READ_ONLY',
message: PREVIEW_READ_ONLY_MESSAGE,
},
})
}