Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/agents/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ success.

## Application answer updates

Extension suggestions can belong to an application fill without a catalog job.
When posting text is missing, the suggestion service uses the owning user's
stored fill URL to read supported hosted Ashby, Greenhouse or Lever posting
APIs. It does not create a catalog or board row. Arbitrary application URLs
are not scraped, and unavailable context remains explicit in the request.
Saved answer revisions and usage accounting are the same as for catalog jobs.

Jumping from the unanswered-field checklist focuses the form control without
collapsing the extension. Panel minimization is an explicit user action.

The extension's per-field review uses `GET /user/apply/fills/{id}` and
`PUT /user/apply/fills/{id}/answer`. The latter requires the field's
`answer_revision`, saves `review_value` and feedback, and appends history without
Expand Down
4 changes: 2 additions & 2 deletions extension/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion extension/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "job-tracker-apply",
"private": true,
"version": "0.3.4",
"version": "0.3.5",
"type": "module",
"scripts": {
"dev": "node scripts/entrypoints.mjs && wxt",
Expand Down
5 changes: 1 addition & 4 deletions extension/runtime/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export async function startApplication(adapter, adapterContext, lifecycle) {
const reader = adapter || { ready: () => false, submitButton: () => null, submitted: () => false };
// Stamped into every report, so a report from a build the person has not
// reloaded yet is told apart from a bug (reports 9 to 11, 2026-09-08).
const BUILD = "0.3.4 remaining-field checklist";
const BUILD = "0.3.5 external application context";

// A message to the extension's background worker. After the extension is
// reloaded, a page that was already open keeps the old script, whose
Expand Down Expand Up @@ -766,9 +766,6 @@ export async function startApplication(adapter, adapterContext, lifecycle) {
if (action === "locate") {
const node = field._el || field._ctl || field._box || boxOf(field);
if (node) {
prefs.collapsed = true;
surface.appearance({ theme: prefs.theme, collapsed: true });
surface.mark("jt-min", { text: "+", label: "Expand panel", expanded: false });
node.scrollIntoView?.({ block: "center", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
const selector = 'input:not([type="hidden"]):not([type="file"]):not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [contenteditable="true"]';
const target = node.matches(selector) ? node : [...node.querySelectorAll(selector)].find(el => el.getClientRects().length);
Expand Down
2 changes: 1 addition & 1 deletion extension/tests/browser/review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ test("remaining fields link to controls and disappear as the person fills them",
await remaining.getByRole("button", { name: "Portfolio URL Unanswered", exact: true }).click();
await expect(page.locator("#portfolio")).toBeFocused();
await page.locator("#portfolio").fill("https://example.com/portfolio");
await page.getByRole("button", { name: "Expand panel", exact: true }).click();
await expect(page.getByRole("button", { name: "Minimise panel", exact: true })).toBeVisible();
await expect(remaining.getByRole("button")).toHaveCount(1);
await expect(remaining.getByRole("button", { name: "Resume / CV Unanswered", exact: true })).toBeVisible();
});
Expand Down
40 changes: 40 additions & 0 deletions src/api/apply/posting_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Posting evidence for applications that need not belong to the catalog."""

from __future__ import annotations

import asyncio
import re
from urllib.parse import urlsplit

from api import db
from core.fetching import ats
from core.fetching.forms import posting_urls


async def external_posting(user_id: int, fill_id: int) -> str | None:
fill = db.query_one(
"SELECT url FROM application_fills WHERE id = %s AND user_id = %s",
(fill_id, user_id),
)
if not fill:
return None
# Only hosted ATS routes whose resolvers construct fixed public API
# endpoints. Never scrape arbitrary URLs supplied by an application form.
hosts = {
"jobs.ashbyhq.com",
"jobs.lever.co",
"jobs.eu.lever.co",
"boards.greenhouse.io",
"job-boards.greenhouse.io",
}
raw = urlsplit(fill["url"])
if raw.scheme != "https" or raw.netloc not in hosts:
return None
url = ats.canonicalize(posting_urls(fill["url"])[0])
if not url:
return None
# Tokens are path components, not encoded paths or query fragments.
if not re.fullmatch(r"/[A-Za-z0-9_-]+/(?:jobs/)?[A-Za-z0-9-]+", urlsplit(url).path):
return None
result = await asyncio.to_thread(ats.resolve, url)
return result.text if result.ok else None
10 changes: 6 additions & 4 deletions src/api/routers/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
)
from api.ai import access as ai_access
from api.apply import drafting as drafts
from api.apply import fill_answers
from api.apply import fill_answers, posting_context
from api.apply import policy as extension_policy
from api.apply import recipes as extension_recipes
from api.auth import AuthedUser, require_user
Expand Down Expand Up @@ -669,9 +669,11 @@ async def suggest(body: SuggestBody, user: AuthedUser = Depends(require_user)) -
parts = []
if job:
parts.append(f"Job: {job['title']} at {job['company']}")
if any(f.kind in {"text", "long"} and not f.options for f in fields):
posting = get_content(job["url"]) or ""
parts.append(f"Posting:\n{posting[:6000] or '(no posting text captured)'}")
if any(f.kind in {"text", "long"} and not f.options for f in fields):
posting = get_content(job["url"]) if job else None
if not posting and body.fill_id is not None:
posting = await posting_context.external_posting(user.id, body.fill_id)
parts.append(f"Posting:\n{(posting or '')[:6000] or '(no posting text captured)'}")
parts.append("Fields:\n" + json.dumps([f.model_dump() for f in fields], indent=1))
if body.review_only:
parts.append(
Expand Down
95 changes: 95 additions & 0 deletions tests/test_application_motivation.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from api import db
from api.apply import drafting

Expand Down Expand Up @@ -64,3 +66,96 @@ def test_missing_company_evidence_is_explicit():
rules = drafting.instructions(None)
assert "Do not invent company details" in rules
assert "empty string" in rules


def test_external_application_supplies_posting_without_creating_catalog_job(
client, user_headers, monkeypatch
):
from api import ai
from core.fetching import ats

url = "https://jobs.ashbyhq.com/ivo-inc/b31e7195-37dd-4631-8648-422cecbb3f83/application?utm_source=Otta"
fields = [{"key": "why", "label": "Why Ivo?", "kind": "long", "options": []}]
fill = client.post(
"/v1/user/apply/resolve",
headers=user_headers,
json={"url": url, "host": "ashby", "fields": fields},
)
assert fill.status_code == 200, fill.text
assert fill.json()["job_id"] is None
captured = []
fetched = []

def resolve(target):
fetched.append(target)
return ats.AtsResult(
ats.Status.OK,
"Software Engineer at Ivo. Build tools for legal contract review.",
"ashby",
)

async def parse(cfg, rules, text, schema):
captured.append(text)
return schema(
answers=[{"key": "why", "answer": "I want to build useful tools for legal teams."}]
), {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}

monkeypatch.setattr(ats, "resolve", resolve)
monkeypatch.setattr(ai, "parse", parse)
monkeypatch.setattr(
"api.budget.resolve_ai_config",
lambda uid, ent: type("Cfg", (), {"model": "m", "key_source": "owner"})(),
)
response = client.post(
"/v1/user/apply/suggest",
headers=user_headers,
json={"fill_id": fill.json()["fill_id"], "fields": fields},
)
assert response.status_code == 200, response.text
assert "Build tools for legal contract review." in captured[0]
assert fetched == [url.split("/application")[0]]
assert db.query_one("SELECT count(*) AS n FROM jobs")["n"] == 0


@pytest.mark.asyncio
@pytest.mark.parametrize(
"url",
[
"https://jobs.ashbyhq.com.evil.test/org/00000000-0000-0000-0000-000000000000",
"http://127.0.0.1/private",
"https://jobs.ashbyhq.com/org%2f..%2fprivate/00000000-0000-0000-0000-000000000000",
],
)
async def test_external_context_never_fetches_untrusted_targets(f, monkeypatch, url):
from api.apply.posting_context import external_posting
from core.fetching import ats

owner = f.make_user()
row = db.query_one(
"INSERT INTO application_fills (user_id, url, host, fields) VALUES (%s, %s, 'ashby', '[]') RETURNING id",
(owner, url),
)

def forbidden(url):
raise AssertionError("untrusted target reached the fetcher")

monkeypatch.setattr(ats, "resolve", forbidden)
assert await external_posting(owner, row["id"]) is None


@pytest.mark.asyncio
async def test_external_context_cannot_read_another_users_fill(f, monkeypatch):
from api.apply.posting_context import external_posting
from core.fetching import ats

owner, other = f.make_user(), f.make_user()
row = db.query_one(
"INSERT INTO application_fills (user_id, url, host, fields) VALUES (%s, %s, 'ashby', '[]') RETURNING id",
(owner, "https://jobs.ashbyhq.com/ivo-inc/b31e7195-37dd-4631-8648-422cecbb3f83"),
)

def forbidden(url):
raise AssertionError("another user's fill reached the fetcher")

monkeypatch.setattr(ats, "resolve", forbidden)
assert await external_posting(other, row["id"]) is None
Loading