-
Notifications
You must be signed in to change notification settings - Fork 18
feat: implement read-only mode for demo organization and add error handling #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f0a536c
f5d0b6e
2c59a84
adfbb99
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| }) | ||
| } | ||
| }) | ||
| 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' | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove explicit import — Nitro auto-imports all utilities from 🛠️ Proposed fix import { eq } from 'drizzle-orm'
import * as schema from '../database/schema'
-import { createPreviewReadOnlyError } from '../utils/previewReadOnly'📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||
|
|
||||||||
| /** | ||||||||
| * Demo guard middleware — blocks write operations (POST, PATCH, PUT, DELETE) | ||||||||
|
|
@@ -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 | ||||||||
|
|
||||||||
|
|
@@ -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 }) | ||||||||
|
|
@@ -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() | ||||||||
| } | ||||||||
| }) | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🛠️ 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 |
||
|
|
||
| if (!isPreview) { | ||
| if (!explicitUrl) { | ||
|
|
@@ -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}`) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Any environment name that starts with
The pre-existing 🛠️ 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 |
||
| || | ||
| /^pr(?:-|\d)/.test(name) | ||
| || name.includes('pr-') | ||
| || name.includes('pr ') | ||
| || name.includes('pull request') | ||
| || name.includes('pull-request') | ||
| || name.includes('preview') | ||
|
|
@@ -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) | ||
|
|
||
| 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, | ||
| }, | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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].tsand 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].tsshould only be changed for auth mount path modifications.🤖 Prompt for AI Agents