Sno Station
Sno Station은 AI 에이전트를 하나의 팀으로 묶는 오픈소스 워크스테이션으로, 로컬 머신에서 에이전트 협업을 가능하게 함
Job Jet은 Chrome 확장프로파일 기반 지원서 자동 작성·맞춤·지원 현황 추적 기능을 제공합니다
Job Jet is a Chrome extension that fills job application forms from your profile, tailors your resume to each role, and tracks every application.
플러그인TypeScript · JavaScriptGitHub @adityakmr7온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A Chrome extension that recognizes job application pages on (in principle)
any site, and helps fill them out: autofill from a saved profile, or generate
a resume tailored to the specific job description via AI.
Full system design, data model, and the reasoning behind what's built (and
deliberately not built) is in docs/ARCHITECTURE.md.
Release notes are in CHANGELOG.md.README 전문 보기 ↗A Chrome extension that recognizes job application pages on (in principle) any site, and helps fill them out: autofill from a saved profile, or generate a resume tailored to the specific job description via AI.
Full system design, data model, and the reasoning behind what's built (and
deliberately not built) is in docs/ARCHITECTURE.md.
Release notes are in CHANGELOG.md.
apps/
web/ Next.js app — auth, dashboard, legal pages, all backend API routes
extension/ Chrome MV3 extension (Vite + CRXJS + React), side panel UI
packages/
shared/ Shared TS types/zod schemas: Profile, Resume, Application, FieldMapping
scripts/ Repo-level tooling (extension release zip)
@crxjs/vite-plugin, React side panel.apps/web/drizzle.gemini-2.5-flash). The provider is
isolated in apps/web/src/lib/ai.ts (getModel()), so swapping models or
providers is a one-file change.Requirements: Node.js 22+ (see .nvmrc; nvm use) and npm.
npm ci
# 1. Web app / API — http://localhost:3001 (pinned; 3000 is often taken)
cp apps/web/.env.example apps/web/.env.local # fill in values (or `vercel env pull`)
npm run db:migrate --workspace=apps/web # create/upgrade tables
npm run dev:web
# 2. Extension
cp apps/extension/.env.example apps/extension/.env.development # dev section
npm run dev:extension
Then load apps/extension/dist as an unpacked extension in
chrome://extensions (Developer mode on). After a rebuild, click the
extension's reload icon — Chrome doesn't pick up on-disk changes to an
already-loaded unpacked extension on its own.
Keep VITE_API_BASE_URL matched to the port npm run dev:web binds to. In
development, with ALLOWED_EXTENSION_IDS empty, any unpacked extension can
connect; open the side panel, click Connect to Job Jet and approve on the
web page that opens.
Local Postgres instead of Neon (optional): when DATABASE_URL points at
localhost/127.0.0.1, the app uses node-postgres instead of Neon's HTTP
driver, so a plain local Postgres works for development
(DATABASE_URL=postgres://user:pass@localhost:5432/jobjet).
Accounts are stored in our own database by
Better Auth (better-auth npm package, Drizzle
adapter). No third-party auth service is involved; Google sign-in uses plain
Google OAuth.
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET are set). Sessions
are httpOnly, SameSite=Lax cookies (Secure + __Secure- prefix in
production), valid 30 days, refreshed daily. Pages: /sign-in, /sign-up,
/forgot-password, /reset-password, /verify-email,
/dashboard/account (change password, connected devices, delete account).apps/web/src/lib/auth/session.ts): requireUser(req)
in every API route (401 JSON otherwise; ownership checks and rate limits are
unchanged and still scope every query to user.id), requirePageUser() /
getSession() in server components. src/proxy.ts only does a fast
cookie-presence redirect; the real session check happens server-side.auth_rate_limit), per IP: sign-in 10/minute, sign-up 5 per 10 minutes,
password-reset and verification emails 5 per 10 minutes, change-password 10
and delete-account 5 per 10 minutes; 100/minute for everything else./dashboard/account) requires the password (or a
recent sign-in for Google-only accounts), deletes the user's resume files
from Vercel Blob first, then the user row — profiles, resumes, applications,
sessions and linked accounts cascade.Email sending is pluggable (apps/web/src/lib/email.ts):
RESEND_API_KEY set → emails go through Resend, and email verification
is required before password sign-in (Google accounts are verified by
Google).https://<web>/extension-connect?ext=<extension id>&state=<one-time nonce>
in a tab (the nonce is kept in chrome.storage.session, single use, 10
minutes).POST /api/auth/extension/token with its cookie session
(Better Auth's origin check applies); the server refuses extension IDs not
in ALLOWED_EXTENSION_IDS and mints a new, separate session labelled
Job Jet extension (<id>), replacing that install's previous one.chrome.runtime.sendMessage(extId, …). The manifest's
externally_connectable only lets the production web origin (plus
localhost in dev builds) message the extension, and the background worker
also checks the exact sender origin, the /extension-connect path, the
message schema and the nonce.chrome.storage.local and sends it as
Authorization: Bearer <token>; credentials: "omit" keeps it
bearer-only. On a 401 it clears the token and shows Reconnect.
Sign out revokes the session server-side and clears it locally; the web
Account settings → Where you're signed in can disconnect it too.The server accepts a bearer token only if (a) the request doesn't come from a
web origin (browsers always send Origin cross-origin, and pages can't forge
chrome-extension://), and (b) the session was minted for an extension ID
that is still allowlisted — and, when an Origin is present, for that exact
extension. A web session cookie can't be replayed as a bearer token, and a
bearer token can't mint more tokens. Tokens are HMAC-signed with
BETTER_AUTH_SECRET, so a database leak alone doesn't yield usable tokens.
See SECURITY.md for the reasoning.
https://<domain>/privacy, terms https://<domain>/terms.
Scopes: only openid, .../auth/userinfo.email,
.../auth/userinfo.profile (non-sensitive, no verification review
needed). User type External; while in Testing, add test users, then
Publish app for launch.https://<domain> and
http://localhost:3001.https://<domain>/api/auth/callback/google and
http://localhost:3001/api/auth/callback/google (add preview domains
only if you really sign in on them).GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
(Vercel env, and .env.local for development). Use separate clients for
production and development if you prefer.Account linking is enabled for Google: signing in with Google using the same email as an existing email+password account links the two (Google has verified the address), which is also how migrated Clerk users regain access.
The app isn't launched, so this is optional. Migration 0002_better_auth
already keeps existing rows: every old users row becomes a Better Auth
user with the same id (the Clerk user_… id), so profiles, resumes and
applications stay attached. Those users have no password; they either sign in
with Google (linked by email) or use Forgot password.
To bring over names/verification status (or users who never touched the database) from a Clerk export (Dashboard → Users → Export, CSV or JSON):
cd apps/web
npm run db:migrate # first
npm run auth:migrate-clerk -- --file ./clerk-users.csv # dry run (default): prints the plan
npm run auth:migrate-clerk -- --file ./clerk-users.csv --apply # writes, one transaction per user
For each Clerk user (matched by primary email) it creates the Better Auth
user with the Clerk name and emailVerified, moves profiles/resumes/
applications from the Clerk id to a fresh Better Auth id, or merges them
into an existing Better Auth account with the same email. Re-running is safe.
It never imports password hashes (Clerk's bcrypt digests aren't portable). Take a database backup (Neon
branch) first.
apps/web/.env.example)| Variable | Purpose |
|---|---|
DATABASE_URL | Neon Postgres connection string |
GOOGLE_GENERATIVE_AI_API_KEY | Gemini API key (resume parsing, tailoring, autofill matching) |
BETTER_AUTH_SECRET | Signs session cookies/tokens. openssl rand -base64 32; different per environment |
BETTER_AUTH_URL | Public origin of the app (https://<domain>, http://localhost:3001 locally) |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | Optional: Google sign-in (see setup) |
RESEND_API_KEY / EMAIL_FROM | Optional: verification + password-reset emails via Resend; also turns on required email verification |
BLOB_READ_WRITE_TOKEN | Vercel Blob (private store) |
ALLOWED_EXTENSION_IDS | Comma-separated Chrome extension IDs that may connect (get tokens), use bearer auth and CORS. Empty = any extension in dev, none in production |
NEXT_PUBLIC_CONTACT_EMAIL | Contact address shown on /privacy and /terms |
Secrets are read lazily at request time, so next build works without them.
apps/extension/.env.example)| Variable | Purpose |
|---|---|
VITE_API_BASE_URL | Web app origin (no path): API base URL, the /extension-connect page, and the only origin allowed to message the extension; its host is excluded from job detection |
JOBJET_ALLOW_DEV_CONFIG | Optional, not bundled: true downgrades production checks to warnings |
The build fails if a required value is missing or malformed. Production
builds (npm run build, mode production, reads .env.production) also fail
if the backend is localhost or not https. Use
npm run build:dev --workspace=apps/extension (reads .env.development) for
a local unpacked build.
Run from the repo root:
| Command | What it does |
|---|---|
npm run dev:web / npm run dev:extension | Dev servers |
npm run lint | ESLint in every workspace |
npm run typecheck | tsc --noEmit everywhere (next typegen first for web) |
npm test | Vitest unit tests in every workspace |
npm run build:web / npm run build:extension | Production builds |
npm run release:extension | Production extension build + Chrome Web Store zip in release/ |
npm run format / npm run format:check | Prettier (not enforced in CI yet) |
npm run test:fixtures | Serve the detection fixture pages on :4000 |
Database scripts (--workspace=apps/web): db:generate, db:migrate,
db:migrate:env, db:baseline, db:studio, and db:push (dev only).
npm test runs Vitest in each workspace:
apps/extension/tests (jsdom) — job-page detection against the checked-in
fixture pages, own-app exclusion, heuristic autofill mapping, the
Greenhouse/Lever/Ashby adapters against field data captured from live
postings, field collection (labels, honeypots, shadow DOM), and build-env
validation.apps/web/tests — CORS, request validation and size limits, rate limiting,
error handling, field-path resolution, the AI-output merge logic, and auth:
real Better Auth + the real API routes against an in-process Postgres
(PGlite) with the real migrations — 401s, cookie vs
bearer rules, ownership (no IDOR), sign-out revocation, account deletion,
the 0002 migration and the Clerk import script (the AI SDK and Blob are
mocked; no network or external database needed).packages/shared/tests — zod schemas and field signatures.CI (.github/workflows/ci.yml) runs lint, typecheck, tests and both builds
on every push and pull request to main.
Real ATS sites are slow and inconsistent to test against repeatedly, so
apps/extension/test-fixtures/ has local pages exercising the detection
heuristic and autofill directly:
npm run test:fixtures # serves http://localhost:4000
/careers/senior-frontend-engineer/apply/ — a single-step job
application form (positive case) — the floating button should appear,
and Autofill should fill most text/select fields from your saved profile./careers/product-manager/apply/ — a 5-step wizard. Fields only exist in
the DOM for the currently active step (not just CSS-hidden), and the URL
hash changes per step — the harder, more realistic case, closer to how
real SPA-driven ATS wizards behave. Re-open the side panel (or click
Autofill again) after each "Next" to fill that step's fields./about/ — a plain content page (negative control) — the button should
not appear here.The schema lives in apps/web/src/db/schema.ts; SQL migrations are generated
into apps/web/drizzle/.
npm run db:generate --workspace=apps/web and
commit the new migration.npm run db:migrate --workspace=apps/web (uses
.env.local) or DATABASE_URL=... npm run db:migrate:env --workspace=apps/web.db:migrate, not db:push. db:push is only for
throwaway local databases.Existing database created with db:push (one-time): it already has the
tables from 0000_initial_schema, so mark that migration as applied instead
of running it, then migrate:
cd apps/web
DATABASE_URL=... node scripts/db-baseline.mjs # or: npm run db:baseline (uses .env.local)
DATABASE_URL=... npm run db:migrate:env # applies 0001_add_rate_limits and later
db-baseline.mjs refuses to run on an empty database or one that already
has migration history.
Web (Vercel): the Vercel project's Root Directory should be apps/web. Set every variable from
apps/web/.env.example in the Vercel project (production values: pk_live_/
a fresh BETTER_AUTH_SECRET, BETTER_AUTH_URL=https://<domain>, Google OAuth
credentials, RESEND_API_KEY/EMAIL_FROM, ALLOWED_EXTENSION_IDS with the
Chrome Web Store extension ID, NEXT_PUBLIC_CONTACT_EMAIL). Run migrations against the
production database before (or right after) deploying a release that
includes a new migration.
Extension (Chrome Web Store): once the production domain exists, run this from the repo root:
cat > apps/extension/.env.production <<'ENV'
VITE_API_BASE_URL=https://<your-domain> # deployed web app origin, no trailing slash
ENV
npm ci && npm run release:extension
# -> release/job-jet-extension-v<version>.zip (git-ignored; upload this file)
The build refuses http:// hosts and localhost in production
mode. Store listing text, permission justifications and images are in
store-assets/. The listing also needs the public
privacy policy URL (https://<your-domain>/privacy). After the first upload,
add the store extension ID to ALLOWED_EXTENSION_IDS (web env) — until then
the published extension can't connect.
version in apps/extension/package.json (the manifest version comes
from it) and keep apps/web, packages/shared and the root in sync.[Unreleased] entries in CHANGELOG.md under the new version.git tag v<version> && git push origin v<version>) and
create a GitHub release from the changelog section.npm run release:extension and upload the zip to the Chrome Web Store.apps/web/src/lib/rate-limit.ts) and all JSON bodies are size-capped and
zod-validated (apps/web/src/lib/validation.ts).ALLOWED_EXTENSION_IDS;
every route still requires a session (web cookie, or an extension bearer
token bound to an allowlisted extension).apps/web/src/lib/security-headers.ts. See SECURITY.md for
the threat model, permission justifications and how to report issues.The visual identity, tokens, components and accessibility rules are documented
in DESIGN.md.
apps/extension/src/lib/detect.ts — a fast-path hostname allowlist for known
ATSs (Greenhouse, Lever, Workday, Ashby, iCIMS, SmartRecruiters, LinkedIn,
etc.) OR'd with a generic scored heuristic (URL/title keywords, page-text
keywords like "cover letter" / "work authorization", form-field name/label
keywords, presence of a file upload, input count) so bespoke company career
pages and unlisted ATSs still get picked up. Runs on document_idle and
re-runs on SPA route changes via a MutationObserver, since job boards are
almost all client-side routed.
On a positive detection, a floating launcher is injected into a closed shadow
root (host-page-CSS-proof, and unreachable by page scripts) in the bottom-right
corner; the user can hide it for the page. Clicking it opens the
chrome.sidePanel, which shows what was detected on the page, an "Autofill"
action, a "Tailor my resume to this job" action (auto-extracts the JD text
from the page), and a local skill-match summary.
Layered matching, cheapest first — see docs/ARCHITECTURE.md for the full
writeup, including what got found by inspecting two real live job postings:
apps/extension/src/lib/autofill-map.ts matches
detected fields against the user's saved profile purely by name/id/label
keyword, entirely client-side. Fetches the profile from /api/profile
cross-origin (the extension's session token as a Bearer header —
see src/lib/api.ts and the backend's src/lib/cors.ts). Deliberately
never fills voluntary EEO self-identification fields (gender, ethnicity,
veteran/disability status, pronouns) — those stay opt-in and manual.apps/extension/src/lib/adapters/ targets each platform's stable field
id/name directly rather than guessing from label text. Verified against
real captured field data from live postings on all three platforms
(npm run verify:adapters --workspace=apps/extension). Workday has no
adapter yet — its stable fields are behind an account-creation step not
verified live; see docs/ARCHITECTURE.md.apps/web/src/app/api/autofill/map. Fields
tiers 1–2 miss get matched against a closed set of known profile
attributes (never a raw value — see apps/web/src/lib/field-paths.ts),
cached per-domain in the field_mappings table so repeat visits to the
same ATS don't re-hit the LLM.Known limitation: browsers restrict script-set input[type=file].files for
security, so resume/file fields are never auto-filled — the side panel
message says so explicitly. A DataTransfer workaround exists for some
sites but is not implemented yet.
DATABASE_URL,
schema pushed via Drizzle — users/profiles/resumes/applications/
field_mappings), Vercel Blob (private store, resume files). Sign-in/
sign-up pages and /dashboard verified working end-to-end.GET/PUT /api/profile (lazy user upsert, zod-validated),
full dashboard editor (basics, links, work authorization, experience
with bullets, education, skills). Verified end-to-end in a real
browser: sign-up → dashboard → edit → save → reload → persisted.Profile/Resume JSON:
POST /api/resume extracts text (pdf-parse/mammoth), stores the
original in Blob, structures it via Gemini (generateObject,
src/lib/resume-parse.ts) into the shared ResumeContent shape.
Dashboard lets you upload and offers to prefill the profile editor
from the result. Verified end-to-end with a real Gemini key and a
real PDF — every field (name, contact info, both links, both jobs
with all bullets, education) came back correct. Two real bugs found
and fixed along the way:
- pdf-parse (via pdfjs-dist) broke under Next's server bundling
("Setting up fake worker failed") — fixed via serverExternalPackages
in next.config.ts.
- The model choice mattered a lot: gemini-flash-latest was
overloaded (503), gemini-3.6-flash worked but took ~29s and
leaked stray internal self-check narration into the summary
field ("Cheating check. Accent check..."). Switched to
gemini-2.5-flash — ~1s, clean output. See src/lib/ai.ts.@clerk/chrome-extension's
ClerkProvider in the side panel, syncHost pointed at the web app
with __experimental_syncHostListener (live session sync instead of
requiring the panel to be closed/reopened — a known limitation of the
SDK without that flag), manifest updated with the permission
it needs. Extension ID registered in Clerk's via
the Backend API. : autofill ran against a
real signed-in profile (not test data) via the actual side panel —
correct fields filled, EEO/file/no-data-source fields correctly
skipped, confirmed against the profile's real (unset) data via the
API rather than assumed.
MIT © 2026 Aditya Kumar
cookiesallowed_origins/extension-connect — see
Authentication.)docs/ARCHITECTURE.md.apps/web/public/logo-mark.svg,
procedurally-generated matching extension icons at every size,
Next.js app-icon/apple-icon conventions). Not derived from any other
product's actual mark.apps/web/src/app/page.tsx) — hero, feature
grid, how-it-works, CTA, using Job Jet's own copy and design.
Scope note: built with a comparable feature set to the "AI job
copilot" product category, not as a copy of any specific competitor's
branding, copy, or visual design — that would be a trademark/
copyright problem regardless of who's asking. Verified rendering
correctly (light/dark, signed-in/out states) in a real browser.@react-pdf/renderer)
→ Blob. Safe-by-construction against fabrication — the model can
only reword existing content, never touch facts (name, dates,
employers, etc.); see docs/ARCHITECTURE.md. Verified end-to-end
with a real Gemini call: correctly tailored summary, reordered
skills, valid downloaded PDF. Download works cross-origin from the
extension too (the Blob store is private, so this needed its own
authenticated streaming route, not just a public URL).POST /api/autofill/map. Safe by construction like resume tailoring:
the model only ever picks a key from a closed, hand-written list of
known profile attributes (apps/web/src/lib/field-paths.ts), never
returns a value — the real value is resolved from the user's actual
profile in code. Backed by the crowdsourced field_mappings table
(keyed by domain + normalized field signature), so the same field
wording on the same ATS only costs one real model call across all
users, not one per user./dashboard/applications. Verified end-to-end (create, upsert-by-url
dedup, status/notes updates through the real API, delete, per-user
scoping) — see docs/ARCHITECTURE.md.