#!/usr/bin/env python3 """supabase-ssr-scan — free Supabase SSR (Next.js) migration & risk scanner by PatchVault (patchvault-81y.pages.dev). Single file, Python 3.8+ standard library only. Reads files locally; sends nothing anywhere. Usage: python3 <(curl -sL https://patchvault-81y.pages.dev/starters/supabase-ssr-scan.py) [dir] [--demo] [--json] [--no-color] If the directory has no matching project files (or --demo is given) it runs a clearly labelled showcase on a built-in SAMPLE repository so you can see the report format. Exit code is always 0 (it is a report, not a gate).""" import json,os,re,sys CFG=json.loads(r'''{"name": "supabase-ssr-scan", "framework": "Supabase SSR (Next.js)", "detect": [".js", ".ts", ".tsx", ".jsx", ".mjs", "package.json"], "rules": [{"id": "sb-import", "sev": "CRITICAL", "title": "Deprecated @supabase/auth-helpers-nextjs import", "rx": "(['\"])@supabase/auth-helpers-(?:nextjs|react|shared)(['\"])", "advice": "Move to @supabase/ssr.", "fix": "\\1@supabase/ssr\\2", "flags": "", "unless": null, "ext": null, "fix_unless": "createServerComponentClient|createRouteHandlerClient|createServerActionClient|createMiddlewareClient|withPageAuth|withApiAuth"}, {"id": "sb-client", "sev": "HIGH", "title": "createClientComponentClient() was removed", "rx": "\\bcreateClientComponentClient\\s*\\(\\s*\\)", "advice": "Use createBrowserClient(url, anonKey).", "fix": "createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)", "flags": "", "unless": null, "ext": null, "fix_unless": null}, {"id": "sb-client-import", "sev": "HIGH", "title": "Named import createClientComponentClient", "rx": "\\bcreateClientComponentClient\\b(?!\\s*\\()", "advice": "Import createBrowserClient from @supabase/ssr.", "fix": "createBrowserClient", "flags": "", "unless": null, "ext": null, "fix_unless": null}, {"id": "sb-server", "sev": "HIGH", "title": "createServerComponentClient / createRouteHandlerClient were removed", "rx": "\\b(?:createServerComponentClient|createRouteHandlerClient|createServerActionClient)\\b", "advice": "Use createServerClient(url, key, { cookies: { getAll, setAll } }) (template in bundle).", "fix": null, "flags": "", "unless": null, "ext": null, "fix_unless": null}, {"id": "sb-middleware", "sev": "HIGH", "title": "createMiddlewareClient was removed", "rx": "\\bcreateMiddlewareClient\\b", "advice": "Use createServerClient in middleware with getAll/setAll and call auth.getUser().", "fix": null, "flags": "", "unless": null, "ext": null, "fix_unless": null}, {"id": "sb-cookies-sync", "sev": "HIGH", "title": "Next.js 15: cookies() is async", "rx": "=\\s*cookies\\(\\)", "advice": "Await it: `const cookieStore = await cookies()`.", "fix": "= await cookies()", "flags": "", "unless": null, "ext": null, "fix_unless": null}, {"id": "sb-getsession", "sev": "CRITICAL", "title": "Server-side getSession() trusts an unverified cookie", "rx": "\\.auth\\.getSession\\(\\)", "advice": "On the server call auth.getUser() (or getClaims()) to verify the JWT.", "fix": null, "flags": "", "unless": null, "ext": [".ts", ".js", ".tsx", ".jsx", ".mjs"], "fix_unless": null}, {"id": "sb-cookie-methods", "sev": "MEDIUM", "title": "cookies get/set/remove are deprecated in @supabase/ssr", "rx": "cookies\\s*:\\s*\\{\\s*get\\s*\\(", "advice": "Implement getAll/setAll.", "fix": null, "flags": "", "unless": null, "ext": null, "fix_unless": null}, {"id": "sb-dep", "sev": "HIGH", "title": "auth-helpers still in package.json", "rx": "\"@supabase/auth-helpers-[a-z]+\"\\s*:", "advice": "Replace with \"@supabase/ssr\".", "fix": null, "flags": "", "unless": null, "ext": ["package.json"], "fix_unless": null}], "samples": {"app/dashboard/page.tsx": "import { cookies } from 'next/headers';\nimport { createServerComponentClient } from '@supabase/auth-helpers-nextjs';\n\nexport default async function Dashboard() {\n const supabase = createServerComponentClient({ cookies });\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) return

Please sign in

;\n return

Welcome {session.user.email}

;\n}\n", "components/Login.tsx": "'use client';\nimport { createClientComponentClient } from '@supabase/auth-helpers-nextjs';\n\nexport function Login() {\n const supabase = createClientComponentClient();\n return ;\n}\n", "middleware.ts": "import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';\nimport { NextResponse } from 'next/server';\n\nexport async function middleware(req) {\n const res = NextResponse.next();\n const supabase = createMiddlewareClient({ req, res });\n await supabase.auth.getSession();\n return res;\n}\n", "lib/admin.ts": "import { cookies } from 'next/headers';\nexport function readTheme() {\n const cookieStore = cookies();\n return cookieStore.get('theme');\n}\n"}, "hours": {"CRITICAL": 1.5, "HIGH": 1.0, "MEDIUM": 0.5}, "hours_note": "Estimate: 1.5 h per CRITICAL, 1.0 h per HIGH, 0.5 h per MEDIUM issue (find, rewrite, re-test by hand).", "buy": "https://buy.stripe.com/5kQbJ33WS7wq9Yo3iLgYU0n", "guide": "https://patchvault-81y.pages.dev/supabase-ssr-migration/?src=cli_terminal", "price": 9}''') SKIP={'node_modules','.git','dist','build','.next','coverage','.vercel','.venv','venv','__pycache__','.turbo','.cache'} SEVS=('CRITICAL','HIGH','MEDIUM') def _rules(): out=[] for r in CFG['rules']: out.append(dict(r,_rx=re.compile(r['rx'],re.I if 'i' in r['flags'] else 0),_un=re.compile(r['unless'],re.I) if r['unless'] else None,_fu=re.compile(r['fix_unless']) if r.get('fix_unless') else None)) return out def matches_detect(rel): b=os.path.basename(rel) return any(b==d or (d.startswith('.') and (b.endswith(d) or b.startswith(d+'.') or b==d)) for d in CFG['detect']) def rule_applies(r,rel): if not r['ext']: return True b=os.path.basename(rel); return any(b==e or b.endswith(e) or (e.startswith('.') and b.startswith(e)) for e in r['ext']) def fix_line(rel,line,R): for r in R: if r['fix'] is None or not rule_applies(r,rel) or (r['_fu'] and r['_fu'].search(line)): continue line=r['_rx'].sub(r['fix'],line) return line def scan_text(rel,text,R): F=[]; lines=text.splitlines() for r in R: if not rule_applies(r,rel) or (r['_un'] and r['_un'].search(text)): continue for n,l in enumerate(lines,1): if r['_rx'].search(l): F.append(dict(sev=r['sev'],id=r['id'],file=rel,line=n,title=r['title'],advice=r['advice'],code=l.rstrip(), fixed=fix_line(rel,l.rstrip(),R) if r['fix'] is not None and not (r['_fu'] and r['_fu'].search(l)) else None,ctx=lines)) return F def collect(root): files=[] for dp,dn,fn in os.walk(root): dn[:]=sorted(x for x in dn if x not in SKIP) for f in sorted(fn): p=os.path.join(dp,f); rel=os.path.relpath(p,root) if f in ('package-lock.json','yarn.lock','pnpm-lock.yaml') or not matches_detect(rel): continue try: if os.path.getsize(p)>2_000_000: continue files.append((rel,open(p,encoding='utf-8',errors='ignore').read())) except OSError: pass return files class C: def __init__(s,on): s.on=on def __call__(s,code,t): return f'\033[{code}m{t}\033[0m' if s.on else t def vis(t): return len(re.sub(r'\033\[[0-9;]*m','',t)) def main(a): try: sys.stdout.reconfigure(errors='replace') except Exception: pass root=next((x for x in a if not x.startswith('-')),'.') uni=(sys.stdout.encoding or '').lower().startswith('utf') c=C(sys.stdout.isatty() and not os.environ.get('NO_COLOR') and '--no-color' not in a and os.environ.get('TERM')!='dumb') R=_rules(); files=[] if '--demo' in a else collect(root); demo=not files if demo: files=list(CFG['samples'].items()) F=[f for rel,t in files for f in scan_text(rel,t,R)] F.sort(key=lambda f:(SEVS.index(f['sev']),f['file'],f['line'])) cnt={s:sum(f['sev']==s for f in F) for s in SEVS} hrs=sum(CFG['hours'][f['sev']] for f in F) buy=CFG['buy']+('&' if '?' in CFG['buy'] else '?')+'src=cli_terminal' if '--json' in a: print(json.dumps(dict(tool=CFG['name'],sample_mode=demo,files_scanned=len(files),counts=cnt,est_manual_hours=hrs,hours_note=CFG['hours_note'], findings=[{k:f[k] for k in ('sev','id','file','line','title','advice','code','fixed')} for f in F],guide=CFG['guide'],fix_bundle=buy),indent=1)); return 0 H,V,TL,TR,BL,BR,LT,RT='─│┌┐└┘├┤' if uni else '-|++++++' badge={'CRITICAL':c('1;97;41',' CRITICAL '),'HIGH':c('1;30;43',' HIGH '),'MEDIUM':c('1;30;46',' MEDIUM ')} print() print(c('1;33',f"⚡ PatchVault {CFG['name']}" if uni else f"PatchVault {CFG['name']}")+c('2',f" · {CFG['framework']}")) if demo: print(c('1;35',f"⚡ No local {CFG['framework']} files detected in current directory — running Live Interactive Showcase on sample repository..." if uni else f"No local {CFG['framework']} files detected in current directory - running Live Interactive Showcase on sample repository...")) print(c('2',' (SAMPLE DATA: the files below are a built-in demo repo, not your code. Run inside your project to scan it.)')) W=64 rows=[('Files scanned',str(len(files))+(' (sample repo)' if demo else '')), ('[CRITICAL] issues',c('1;91',str(cnt['CRITICAL']))),('[HIGH] issues',c('1;93',str(cnt['HIGH']))),('[MEDIUM] issues',c('1;96',str(cnt['MEDIUM']))), ('Est. manual fix time',f'~{hrs:g} h (estimate)'),('PatchVault auto-fix',c('1;92','~12 seconds')+f" ({sum(f['fixed'] is not None for f in F)} auto-fixable)")] print(TL+H*W+TR) t=' SCAN SUMMARY'; print(V+c('1',t)+' '*(W-len(t))+V); print(LT+H*W+RT) for k,v in rows: line=f' {k:<24}{v}'; print(V+line+' '*max(0,W-vis(line))+V) print(BL+H*W+BR) print(c('2',' '+CFG['hours_note'])) if not F: print('\n '+c('1;92','Nothing found. You look ready.')) else: print() for f in F[:60]: print(f" {badge[f['sev']]} {c('1',f['file'])}:{f['line']} {f['title']}") if len(F)>60: print(f' ... and {len(F)-60} more (use --json)') f=F[0]; L=f['ctx']; i=f['line']-1 new=f['fixed'] if f['fixed'] is not None and f['fixed']!=f['code'] else re.match(r'\s*',f['code']).group(0)+('# ' if f['file'].endswith(('.toml','.sh','.yml','.yaml','.py','.env','.example')) else '-- ' if f['file'].endswith('.sql') else '// ')+'PatchVault: '+f['advice'] print('\n'+c('1',f" Before/after preview — first issue ({f['id']})"+(' [sample]' if demo else ''))) print(c('1',f" --- a/{f['file']}")); print(c('1',f" +++ b/{f['file']}")) print(' '+c('2',f"@@ line {f['line']} @@ "+(L[i-1].strip()[:70] if i>0 else ''))) print(' '+c('91','- '+f['code'][:110])); print(' '+c('92','+ '+new[:110])) print(' '+c('2',' '+(L[i+1][:110] if i+1