Skip to content

Commit eff9663

Browse files
committed
fix(careers): harden Ashby parsing and filter edge cases from review
- validate jobUrl as http(s) only; drop postings with unsafe URLs - validate postings individually so one bad row can't empty the board - namespace the all-filter sentinel to avoid colliding with real values - dedupe the job metadata line (fixes duplicate React keys / Remote·Remote) - parse filters server-side so deep-linked views don't flash unfiltered
1 parent 758d902 commit eff9663

7 files changed

Lines changed: 101 additions & 32 deletions

File tree

apps/sim/app/(landing)/careers/careers.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { Suspense } from 'react'
2+
import type { SearchParams } from 'nuqs/server'
23
import { getAshbyJobs } from '@/lib/ashby/jobs'
34
import {
5+
filterPostings,
46
groupByDepartment,
57
JobBoard,
68
JobGroups,
79
} from '@/app/(landing)/careers/components/job-board'
10+
import { careersSearchParamsCache } from '@/app/(landing)/careers/search-params'
811
import { TrustedBy } from '@/app/(landing)/components/trusted-by'
912

13+
interface CareersProps {
14+
searchParams: Promise<SearchParams>
15+
}
16+
1017
/**
1118
* The careers page — a mission-led hero above the live open-roles board. Roles
1219
* are pulled from Sim's public Ashby job board at build/revalidate time
@@ -21,12 +28,15 @@ import { TrustedBy } from '@/app/(landing)/components/trusted-by'
2128
* citation (landing CLAUDE.md → GEO); the roles section owns its own `<h2>`.
2229
*
2330
* Because {@link JobBoard} reads the URL via nuqs (`useSearchParams`), it sits under
24-
* a `<Suspense>` boundary whose fallback is the same list rendered statically
25-
* ({@link JobGroups}) — so the roles are present in the prerendered HTML and the
26-
* filter bar simply pops in on hydration, never flashing an empty frame.
31+
* a `<Suspense>` boundary. The page parses the same `?team=`/`?location=` query on
32+
* the server ({@link careersSearchParamsCache}) and pre-filters the fallback to
33+
* match, so a deep-linked filter renders the correct roles server-side — the list
34+
* never flashes unfiltered before the client board hydrates.
2735
*/
28-
export default async function Careers() {
36+
export default async function Careers({ searchParams }: CareersProps) {
37+
const { team, location } = await careersSearchParamsCache.parse(searchParams)
2938
const postings = await getAshbyJobs()
39+
const fallbackGroups = groupByDepartment(filterPostings(postings, team, location))
3040

3141
return (
3242
<main id='main-content'>
@@ -67,7 +77,7 @@ export default async function Careers() {
6777
Open roles
6878
</h2>
6979

70-
<Suspense fallback={<JobGroups groups={groupByDepartment(postings)} />}>
80+
<Suspense fallback={<JobGroups groups={fallbackGroups} />}>
7181
<JobBoard postings={postings} />
7282
</Suspense>
7383

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { JobBoard } from './job-board'
2-
export { groupByDepartment, JobGroups } from './job-groups'
2+
export { filterPostings, groupByDepartment, JobGroups } from './job-groups'

apps/sim/app/(landing)/careers/components/job-board/job-board.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ChipSelect, type ChipSelectOption } from '@sim/emcn'
44
import { useQueryStates } from 'nuqs'
55
import type { CareerPosting } from '@/lib/ashby/jobs'
66
import {
7+
filterPostings,
78
groupByDepartment,
89
JobGroups,
910
} from '@/app/(landing)/careers/components/job-board/job-groups'
@@ -49,13 +50,7 @@ export function JobBoard({ postings }: JobBoardProps) {
4950
uniqueSorted(postings.map((p) => p.location).filter(Boolean)),
5051
'All locations'
5152
)
52-
const groups = groupByDepartment(
53-
postings.filter(
54-
(p) =>
55-
(team === ALL_FILTER_VALUE || p.department === team) &&
56-
(location === ALL_FILTER_VALUE || p.location === location)
57-
)
58-
)
53+
const groups = groupByDepartment(filterPostings(postings, team, location))
5954

6055
return (
6156
<div className='flex flex-col gap-10'>

apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
11
import { cn } from '@sim/emcn'
22
import { ArrowRight } from '@sim/emcn/icons'
33
import type { CareerPosting } from '@/lib/ashby/jobs'
4+
import { ALL_FILTER_VALUE } from '@/app/(landing)/careers/search-params'
45

56
export interface DepartmentGroup {
67
department: string
78
postings: CareerPosting[]
89
}
910

11+
/**
12+
* Narrows postings to a selected Team and Location, treating {@link ALL_FILTER_VALUE}
13+
* as "any". Shared by the server-rendered fallback and the client board so a
14+
* deep-linked filter resolves to the exact same set on both sides.
15+
*/
16+
export function filterPostings(
17+
postings: CareerPosting[],
18+
team: string,
19+
location: string
20+
): CareerPosting[] {
21+
return postings.filter(
22+
(posting) =>
23+
(team === ALL_FILTER_VALUE || posting.department === team) &&
24+
(location === ALL_FILTER_VALUE || posting.location === location)
25+
)
26+
}
27+
1028
/**
1129
* Buckets postings by department, preserving their incoming order (the fetcher
1230
* pre-sorts by department then title). Shared by the interactive board and its
@@ -74,8 +92,18 @@ interface JobRowProps {
7492
* tints the row and advances the arrow.
7593
*/
7694
function JobRow({ posting }: JobRowProps) {
77-
const meta = [posting.location, posting.employmentType, posting.workplaceType].filter(Boolean)
78-
if (posting.compensationSummary) meta.push(posting.compensationSummary)
95+
// De-duplicate: a remote posting normalizes both location and workplaceType to
96+
// "Remote", which would otherwise render "Remote · Remote" and collide as keys.
97+
const meta = Array.from(
98+
new Set(
99+
[
100+
posting.location,
101+
posting.employmentType,
102+
posting.workplaceType,
103+
posting.compensationSummary,
104+
].filter((value): value is string => Boolean(value))
105+
)
106+
)
79107

80108
return (
81109
<a

apps/sim/app/(landing)/careers/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SearchParams } from 'nuqs/server'
12
import { buildLandingMetadata } from '@/lib/landing/seo'
23
import Careers from '@/app/(landing)/careers/careers'
34

@@ -11,6 +12,6 @@ export const metadata = buildLandingMetadata({
1112
keywords: 'Sim careers, Sim jobs, AI workspace jobs, AI agent engineering jobs, open source jobs',
1213
})
1314

14-
export default function Page() {
15-
return <Careers />
15+
export default function Page({ searchParams }: { searchParams: Promise<SearchParams> }) {
16+
return <Careers searchParams={searchParams} />
1617
}
Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
import { parseAsString } from 'nuqs/server'
1+
import { createSearchParamsCache, parseAsString } from 'nuqs/server'
22

3-
/** Sentinel value for an inactive filter — matches every posting. */
4-
export const ALL_FILTER_VALUE = 'all'
3+
/**
4+
* Sentinel value for an inactive filter — matches every posting. Namespaced with
5+
* underscores so it can never collide with a real Ashby department or location
6+
* value (e.g. a team literally named "all").
7+
*/
8+
export const ALL_FILTER_VALUE = '__all__'
59

610
/**
711
* Co-located, typed URL query params for the careers job board's Team and
812
* Location filters. Shareable, deep-linkable view-state over an already-rendered
913
* list, so it lives in the URL (nuqs) — never in a store. The values are dynamic
1014
* (departments/locations come from the live board), so plain string parsers with
11-
* an `all` default rather than a fixed literal set. Imported by the client board
12-
* (`useQueryStates`); the page renders every posting statically and filters on
13-
* the client, so no server-side cache is needed.
15+
* an `all` sentinel default rather than a fixed literal set.
1416
*/
1517
export const careersParsers = {
1618
team: parseAsString.withDefault(ALL_FILTER_VALUE),
@@ -23,3 +25,11 @@ export const careersUrlKeys = {
2325
shallow: true,
2426
clearOnDefault: true,
2527
} as const
28+
29+
/**
30+
* Server-side reader for the same parser map. The page parses the request's
31+
* query with this so the statically-rendered fallback is filtered to match a
32+
* deep-linked `?team=`/`?location=` URL — the roles never flash unfiltered before
33+
* the client board hydrates.
34+
*/
35+
export const careersSearchParamsCache = createSearchParamsCache(careersParsers)

apps/sim/lib/ashby/jobs.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ const ASHBY_JOB_BOARD_URL = `https://api.ashbyhq.com/posting-api/job-board/${ASH
1515
/** Revalidate the board hourly, shared across every render (build/revalidate-time cache). */
1616
const REVALIDATE_SECONDS = 3600
1717

18+
/**
19+
* An `http(s)`-only URL. `z.string().url()` alone accepts `javascript:`/`data:`
20+
* (both parse as valid URLs), which would render as a live link, so the scheme is
21+
* pinned explicitly — a posting whose `jobUrl` fails this is dropped rather than
22+
* published as a clickable Apply link.
23+
*/
24+
const httpUrlSchema = z
25+
.string()
26+
.url()
27+
.refine((value) => /^https?:\/\//i.test(value), 'Only http(s) URLs are allowed')
28+
1829
/**
1930
* Tolerant schema for a single Ashby posting. The public board omits several
2031
* fields depending on the posting, so everything beyond the identity/title is
@@ -32,7 +43,7 @@ const ashbyPostingSchema = z
3243
isListed: z.boolean().nullish(),
3344
isRemote: z.boolean().nullish(),
3445
publishedAt: z.string().nullish(),
35-
jobUrl: z.string(),
46+
jobUrl: httpUrlSchema,
3647
applyUrl: z.string().nullish(),
3748
shouldDisplayCompensationOnJobPostings: z.boolean().nullish(),
3849
compensation: z
@@ -43,9 +54,14 @@ const ashbyPostingSchema = z
4354
})
4455
.passthrough()
4556

57+
/**
58+
* The board envelope validates loosely — each posting is validated individually
59+
* in {@link getAshbyJobs} so a single malformed row is skipped rather than
60+
* emptying the entire board.
61+
*/
4662
const ashbyJobBoardSchema = z.object({
4763
apiVersion: z.string().nullish(),
48-
jobs: z.array(ashbyPostingSchema),
64+
jobs: z.array(z.unknown()),
4965
})
5066

5167
/** Human-friendly labels for Ashby's enum-ish string fields. */
@@ -101,16 +117,25 @@ export async function getAshbyJobs(): Promise<CareerPosting[]> {
101117
return []
102118
}
103119

104-
const parsed = ashbyJobBoardSchema.safeParse(await response.json())
105-
if (!parsed.success) {
106-
logger.warn('Ashby job board response failed validation', { issues: parsed.error.issues })
120+
const envelope = ashbyJobBoardSchema.safeParse(await response.json())
121+
if (!envelope.success) {
122+
logger.warn('Ashby job board response failed validation', { issues: envelope.error.issues })
107123
return []
108124
}
109125

110-
return parsed.data.jobs
111-
.filter((job) => job.isListed !== false)
112-
.map(normalizePosting)
113-
.sort(comparePostings)
126+
const postings: CareerPosting[] = []
127+
for (const raw of envelope.data.jobs) {
128+
const parsed = ashbyPostingSchema.safeParse(raw)
129+
if (!parsed.success) {
130+
// Skip the offending posting rather than emptying the whole board.
131+
logger.warn('Skipping malformed Ashby posting', { issues: parsed.error.issues })
132+
continue
133+
}
134+
if (parsed.data.isListed === false) continue
135+
postings.push(normalizePosting(parsed.data))
136+
}
137+
138+
return postings.sort(comparePostings)
114139
} catch (error) {
115140
logger.warn('Ashby job board request threw', { error })
116141
return []

0 commit comments

Comments
 (0)