diff --git a/backend/api_core.py b/backend/api_core.py
index 9999316..6a3086a 100644
--- a/backend/api_core.py
+++ b/backend/api_core.py
@@ -929,6 +929,17 @@ def check_payment_circumvention(text):
return False, "Payment instructions must stay on-platform. Do not include direct payment links, wallet addresses, or off-platform payment instructions."
return True, None
+
+def is_safe_external_url(value):
+ if not value:
+ return True
+ try:
+ parsed = urllib.parse.urlparse(str(value).strip())
+ except Exception:
+ return False
+ return parsed.scheme in ("http", "https") and bool(parsed.netloc)
+
+
VALID_CATEGORIES = [
'web_development', 'mobile_development', 'software_development',
'graphic_design', 'ui_ux_design', 'video_editing', 'photography',
@@ -2295,7 +2306,21 @@ def _handle_routes(db):
if budget <= 0 or budget > 1000000:
return error_response("budget_amount must be positive and <= 1,000,000")
- safe, msg = check_content_safety(body['title'] + " " + body['description'])
+ job_text_parts = [
+ body.get('title', ''),
+ body.get('description', ''),
+ body.get('location_detail', ''),
+ ]
+ raw_required_skills = body.get('required_skills', [])
+ if isinstance(raw_required_skills, list):
+ job_text_parts.extend(str(skill) for skill in raw_required_skills)
+ else:
+ job_text_parts.append(str(raw_required_skills or ''))
+ job_safety_text = " ".join(str(part or '') for part in job_text_parts)
+ safe, msg = check_content_safety(job_safety_text)
+ if not safe:
+ return error_response(f"Job rejected: {msg}", 422)
+ safe, msg = check_payment_circumvention(job_safety_text)
if not safe:
return error_response(f"Job rejected: {msg}", 422)
@@ -2359,11 +2384,23 @@ def _handle_routes(db):
return error_response("Can only edit open or reviewing jobs", 409)
body = get_body()
- if body.get('title') or body.get('description'):
- txt = (body.get('title') or job['title']) + " " + (body.get('description') or job['description'])
- safe, msg = check_content_safety(txt)
- if not safe:
- return error_response(f"Job update rejected: {msg}", 422)
+ update_text_parts = [
+ body.get('title', job['title']),
+ body.get('description', job['description']),
+ body.get('location_detail', job['location_detail'] or ''),
+ ]
+ update_skills = body.get('required_skills', job['required_skills'] or '')
+ if isinstance(update_skills, list):
+ update_text_parts.extend(str(skill) for skill in update_skills)
+ else:
+ update_text_parts.append(str(update_skills or ''))
+ update_safety_text = " ".join(str(part or '') for part in update_text_parts)
+ safe, msg = check_content_safety(update_safety_text)
+ if not safe:
+ return error_response(f"Job update rejected: {msg}", 422)
+ safe, msg = check_payment_circumvention(update_safety_text)
+ if not safe:
+ return error_response(f"Job update rejected: {msg}", 422)
updates = []
vals = []
@@ -2450,6 +2487,17 @@ def _handle_routes(db):
ensure_worker_profile(db, user['id'])
body = get_body()
+ cover_message = body.get("cover_message", "")
+ portfolio_url = body.get("portfolio_url", "")
+ application_safety_text = " ".join([str(cover_message or ""), str(portfolio_url or "")])
+ safe, msg = check_content_safety(application_safety_text)
+ if not safe:
+ return error_response(f"Application rejected: {msg}", 422)
+ safe, msg = check_payment_circumvention(application_safety_text)
+ if not safe:
+ return error_response(f"Application rejected: {msg}", 422)
+ if not is_safe_external_url(portfolio_url):
+ return error_response("Application rejected: portfolio_url must be a valid http(s) URL", 422)
existing = db.execute(
"SELECT id FROM applications WHERE job_id = ? AND worker_id = ?",
[job_id, user['id']]
@@ -2459,7 +2507,7 @@ def _handle_routes(db):
cursor = db.execute(
"INSERT INTO applications (job_id, worker_id, cover_message, portfolio_url) VALUES (?,?,?,?)",
- [job_id, user['id'], body.get("cover_message", ""), body.get("portfolio_url", "")]
+ [job_id, user['id'], cover_message, portfolio_url]
)
app_id = cursor.lastrowid
diff --git a/backend/test_deep_audit_regressions.py b/backend/test_deep_audit_regressions.py
index 3656d4f..9e8a564 100644
--- a/backend/test_deep_audit_regressions.py
+++ b/backend/test_deep_audit_regressions.py
@@ -319,6 +319,92 @@ def test_service_update_rejects_off_platform_payment_instructions_in_tags(self):
self.assertEqual(status, 422, response)
self.assertIn("Payment instructions must stay on-platform", response.get("error", ""))
+ def test_job_creation_rejects_off_platform_payment_instructions(self):
+ db = self.module.get_db()
+ token = "tok-employer"
+ try:
+ db.execute("INSERT INTO users (id,email,password_hash,name) VALUES (1,'employer@example.com','x','Employer')")
+ db.execute("INSERT INTO employer_profiles (user_id) VALUES (1)")
+ db.execute("INSERT INTO sessions (user_id,token,expires_at) VALUES (1,?,datetime('now','+1 day'))", [token])
+ db.commit()
+ finally:
+ db.close()
+
+ payload = {
+ "title": "Pay me via PayPal",
+ "description": "Review the website and use direct payment through paypal.me/example.",
+ "category": "testing",
+ "budget_type": "fixed",
+ "budget_amount": 25,
+ "required_skills": ["qa", "zelle accepted"],
+ }
+ body = json.dumps(payload)
+ self.module._request_ctx.request_method = "POST"
+ self.module._request_ctx.path_info = "/api/v1/jobs"
+ self.module._request_ctx.query_string = ""
+ self.module._request_ctx.http_authorization = f"Bearer {token}"
+ self.module._request_ctx.http_x_api_key = ""
+ self.module._request_ctx.stdin_data = body
+ self.module._request_ctx.content_type = "application/json"
+ self.module._request_ctx.content_length = str(len(body))
+ self.module._request_ctx.remote_addr = "127.0.0.1"
+ with contextlib.redirect_stdout(io.StringIO()) as out:
+ self.module.handle_request()
+ status, response = parse_cgi_output(out.getvalue())
+ self.assertEqual(status, 422, response)
+ self.assertIn("Payment instructions must stay on-platform", response.get("error", ""))
+
+ def test_application_rejects_payment_circumvention_and_unsafe_portfolio_url(self):
+ db = self.module.get_db()
+ token = "tok-worker"
+ try:
+ db.execute("INSERT INTO users (id,email,password_hash,name) VALUES (1,'employer@example.com','x','Employer')")
+ db.execute("INSERT INTO employer_profiles (user_id) VALUES (1)")
+ db.execute("INSERT INTO users (id,email,password_hash,name) VALUES (2,'worker@example.com','x','Worker')")
+ db.execute("INSERT INTO worker_profiles (user_id) VALUES (2)")
+ db.execute("INSERT INTO sessions (user_id,token,expires_at) VALUES (2,?,datetime('now','+1 day'))", [token])
+ db.execute("INSERT INTO jobs (id,employer_id,title,description,category,budget_type,budget_amount,status) VALUES (7,1,'QA Job','Clean scope','testing','fixed',25,'open')")
+ db.commit()
+ finally:
+ db.close()
+
+ payload = {"cover_message": "I can help. Pay me via PayPal or Zelle.", "portfolio_url": "javascript:alert(document.domain)"}
+ body = json.dumps(payload)
+ self.module._request_ctx.request_method = "POST"
+ self.module._request_ctx.path_info = "/api/v1/jobs/7/apply"
+ self.module._request_ctx.query_string = ""
+ self.module._request_ctx.http_authorization = f"Bearer {token}"
+ self.module._request_ctx.http_x_api_key = ""
+ self.module._request_ctx.stdin_data = body
+ self.module._request_ctx.content_type = "application/json"
+ self.module._request_ctx.content_length = str(len(body))
+ self.module._request_ctx.remote_addr = "127.0.0.1"
+ with contextlib.redirect_stdout(io.StringIO()) as out:
+ self.module.handle_request()
+ status, response = parse_cgi_output(out.getvalue())
+ self.assertEqual(status, 422, response)
+ self.assertIn("Payment instructions must stay on-platform", response.get("error", ""))
+
+ payload = {"cover_message": "I can help with this QA task.", "portfolio_url": "javascript:alert(document.domain)"}
+ for attr in ("body_cache", "raw_body"):
+ if hasattr(self.module._request_ctx, attr):
+ delattr(self.module._request_ctx, attr)
+ body = json.dumps(payload)
+ self.module._request_ctx.request_method = "POST"
+ self.module._request_ctx.path_info = "/api/v1/jobs/7/apply"
+ self.module._request_ctx.query_string = ""
+ self.module._request_ctx.http_authorization = f"Bearer {token}"
+ self.module._request_ctx.http_x_api_key = ""
+ self.module._request_ctx.stdin_data = body
+ self.module._request_ctx.content_type = "application/json"
+ self.module._request_ctx.content_length = str(len(body))
+ self.module._request_ctx.remote_addr = "127.0.0.1"
+ with contextlib.redirect_stdout(io.StringIO()) as out:
+ self.module.handle_request()
+ status, response = parse_cgi_output(out.getvalue())
+ self.assertEqual(status, 422, response)
+ self.assertIn("portfolio_url must be a valid http(s) URL", response.get("error", ""))
+
def test_job_creation_notifies_matching_service_workers(self):
db = self.module.get_db()
token = "tok-employer"
@@ -673,6 +759,8 @@ def test_phase2_ui_flow_polish_invariants(self):
"Keep payments on-platform",
"You can apply to jobs before connecting payouts.",
"grid-template-columns:repeat(auto-fit,minmax(240px,1fr))",
+ "function safeExternalHref(value)",
+ "safeExternalHref(a.portfolio_url)",
]
missing = [snippet for snippet in required if snippet not in text]
self.assertEqual(missing, [])
@@ -1547,6 +1635,9 @@ def test_phase5_proof_pack_conversion_layer_exists(self):
self.assertEqual(missing, [])
for rel in ["frontend/index.html", "frontend/starter-offers.html", "frontend/pricing.html", "frontend/sitemap.xml"]:
self.assertIn("proof-packs.html", (REPO_ROOT / rel).read_text(encoding="utf-8", errors="ignore"), rel)
+ starter = (REPO_ROOT / "frontend/starter-offers.html").read_text(encoding="utf-8", errors="ignore")
+ for snippet in ["Clay/GTM QA Sprint", "Draft Clay/GTM QA sprint", "clay_gtm_qa_sprint"]:
+ self.assertIn(snippet, starter)
vercel = json.loads((REPO_ROOT / "frontend/vercel.json").read_text(encoding="utf-8"))
redirects = {(r.get("source"), r.get("destination")) for r in vercel.get("redirects", [])}
self.assertIn(("/proof-packs", "/proof-packs.html"), redirects)
diff --git a/frontend/index.html b/frontend/index.html
index 972459e..ec2dbaa 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -492,6 +492,14 @@
Common agent-delegated work
// ─── Helpers ─────────────────────────────────────────────────
function esc(s) { return s == null ? '' : String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); }
+function safeExternalHref(value) {
+ try {
+ const url = new URL(String(value || '').trim(), window.location.origin);
+ return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
+ } catch {
+ return '';
+ }
+}
function formatCategory(cat) { return (cat || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); }
function safeParseJSON(str, fallback = []) {
try { return JSON.parse(str || JSON.stringify(fallback)); } catch { return fallback; }
@@ -2993,7 +3001,7 @@ ${esc(job.title)}
${statusBadge(a.status||'pending')}
${esc(a.cover_message||'')}
- ${a.portfolio_url ? `View Portfolio → ` : ''}
+ ${safeExternalHref(a.portfolio_url) ? `View Portfolio → ` : ''}
${a.status === 'pending' ? `
Hire ` : ''}
diff --git a/frontend/starter-offers.html b/frontend/starter-offers.html
index 2496b32..445db9c 100644
--- a/frontend/starter-offers.html
+++ b/frontend/starter-offers.html
@@ -72,6 +72,19 @@
Automation QA Sprint
Draft automation QA sprint
+
+ For Clay/GTM teams
+ Clay/GTM QA Sprint
+ $199
+ Human spot-checking for AI-enriched account rows, source accuracy, personalization claims, broken links, duplicates, and risky outbound copy before campaigns go live.
+
+ Review 25–50 rows or a similarly bounded sample.
+ Flag hallucinated claims, unverifiable sources, stale/broken links, duplicates, and ICP-fit risks.
+ Row-level issue table with severity, evidence, and recommendation.
+ Founder-reviewed proof pack while the program is early.
+
+ Draft Clay/GTM QA sprint
+
For offline/manual facts
Real-World Check
diff --git a/frontend/style.css b/frontend/style.css
index 40d64db..28b2b55 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -1920,7 +1920,7 @@
}
.lp-hiw-visual-card p {
font-size: var(--text-sm);
- color: var(--color-text-muted);
+ color: #595650;
line-height: 1.6;
margin: 0;
}
diff --git a/frontend/tests/browser-regression.spec.js b/frontend/tests/browser-regression.spec.js
index d7c0af6..107604a 100644
--- a/frontend/tests/browser-regression.spec.js
+++ b/frontend/tests/browser-regression.spec.js
@@ -42,7 +42,8 @@ test.describe('GoHireHumans public/browser regression suite', () => {
test(`${route.path} renders, has no serious axe violations, and is console-clean`, async ({ page }) => {
const messages = await collectConsole(page);
await setupDeterministicLocalPage(page);
- const response = await page.goto(route.path, { waitUntil: 'networkidle' });
+ const response = await page.goto(route.path, { waitUntil: 'domcontentloaded' });
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
expect(response.status(), route.path).toBeLessThan(400);
await expect(page.locator('body')).toContainText(route.mustContain);
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).exclude('iframe').analyze();
@@ -63,7 +64,8 @@ test.describe('GoHireHumans public/browser regression suite', () => {
await setupDeterministicLocalPage(page);
await page.goto('/pricing.html');
await expect(page.locator('body')).toContainText('Compare Fees');
- await page.goto('/#/services', { waitUntil: 'networkidle' });
+ await page.goto('/#/services', { waitUntil: 'domcontentloaded' });
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
await expect(page.locator('#services-result-count')).toBeVisible();
await expect(page.locator('text=Filter services')).toBeVisible();
});