{"id":1607,"date":"2026-02-09T09:29:09","date_gmt":"2026-02-09T07:29:09","guid":{"rendered":"https:\/\/www.drinkits.lv\/?p=1607"},"modified":"2026-02-24T12:34:04","modified_gmt":"2026-02-24T10:34:04","slug":"hacking-the-forge-csp-how-i-got-client-side-ocr-running-in-jira-cloud","status":"publish","type":"post","link":"https:\/\/www.drinkits.lv\/en\/2026\/02\/09\/hacking-the-forge-csp-how-i-got-client-side-ocr-running-in-jira-cloud\/","title":{"rendered":"Hacking the Forge CSP: how I got client-side OCR running in Jira Cloud"},"content":{"rendered":"<p>It\u2019s 2026. We have AI that can generate video from text, yet when a QA engineer attaches a screenshot of a log file to a Jira ticket, I still find myself manually re-typing error codes like it\u2019s 1995.<\/p>\n<p>So I built it into my Forge app <a href=\"https:\/\/marketplace.atlassian.com\/apps\/2464899201\/attachment-architect\"><strong>Attachment Architect<\/strong><\/a>: a <strong>Scan Text<\/strong> button that turns pixels into copy-pasteable text.<\/p>\n<p>The boring enterprise solution is &#8220;just ship it to Textract\/Vision&#8221;. The problem: screenshots often contain stuff that should <em>never<\/em> leave the browser (PII, API keys, internal URLs, config fragments). I wanted OCR that\u2019s <strong>privacy-first by default<\/strong>. This is also the only way how to keep &#8220;Runs on Atlassian&#8221; badge.<\/p>\n<p>So: <strong>100% client-side OCR<\/strong>. In a Forge Custom UI. Running <strong>Tesseract.js<\/strong> in the browser via <strong>WebAssembly<\/strong>.<\/p>\n<h4>Phase 1 &#8211;\u00a0 the tunnel trap: everything works (until it doesn\u2019t)<\/h4>\n<p>I started in the happy place: <code>forge tunnel<\/code>.<\/p>\n<ul>\n<li>Worker spins up.<\/li>\n<li>WASM loads.<\/li>\n<li>OCR returns text.<\/li>\n<li>I\u2019m feeling unstoppable.<\/li>\n<\/ul>\n<p>This is the part where Forge can quietly mess with your intuition: <strong>tunnel is not Cloud<\/strong>.<\/p>\n<p>Not in the &#8220;different API base URL&#8221; sense &#8211; in the <em>security \/ CSP \/ asset serving<\/em> sense.<\/p>\n<p>In tunnel, you can get to a working state quickly enough that you subconsciously stop thinking about what the platform will do to your code once it\u2019s served from the real Cloud environment. I already managed to complete front-end UI\/UX and was almost ready for production.<\/p>\n<p><a href=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-01-28-131940.png\" data-lbwps-width=\"1013\" data-lbwps-height=\"555\" data-lbwps-srcsmall=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-01-28-131940-18x10.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter wp-image-1614 size-full\" src=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-01-28-131940.png\" alt=\"\" width=\"1013\" height=\"555\" srcset=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-01-28-131940.png 1013w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-01-28-131940-300x164.png 300w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-01-28-131940-768x421.png 768w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-01-28-131940-18x10.png 18w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-01-28-131940-700x384.png 700w\" sizes=\"auto, (max-width: 1013px) 100vw, 1013px\" \/><\/a><\/p>\n<h4>Phase 2 &#8211; deploy to Jira Cloud, click the button, watch it burn<\/h4>\n<p>Then I deployed:<\/p>\n<pre><code class=\"language-bash\">forge deploy -e development<\/code><\/pre>\n<p>Clicked <strong>Scan Text<\/strong> in real Jira Cloud.<\/p>\n<p>Spinner spins forever.<\/p>\n<p>DevTools console turns into a murder board:<\/p>\n<ul>\n<li><code>Refused to create a worker from 'blob:...'<\/code><\/li>\n<li><code>Violates Content Security Policy directive ...<\/code><\/li>\n<li><code>WASM compilation failed<\/code><\/li>\n<\/ul>\n<p>The tunnel didn\u2019t <em>lie<\/em> on purpose. It just didn\u2019t reproduce the same conditions.<\/p>\n<p>In cloud, Forge Custom UI is served from Atlassian\u2019s infrastructure with strict CSP rules. And the way your assets and worker scripts are served\/isolated matters.<\/p>\n<p><a href=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-01-171127.png\" data-lbwps-width=\"2241\" data-lbwps-height=\"1168\" data-lbwps-srcsmall=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-01-171127-18x9.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-1620\" src=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-01-171127.png\" alt=\"\" width=\"2241\" height=\"1168\" srcset=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-01-171127.png 2241w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-01-171127-300x156.png 300w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-01-171127-768x400.png 768w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-01-171127-1536x801.png 1536w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-01-171127-2048x1067.png 2048w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-01-171127-18x9.png 18w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-01-171127-700x365.png 700w\" sizes=\"auto, (max-width: 2241px) 100vw, 2241px\" \/><\/a><\/p>\n<p><a href=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-080105.png\" data-lbwps-width=\"1892\" data-lbwps-height=\"94\" data-lbwps-srcsmall=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-080105-18x1.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-1621\" src=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-080105.png\" alt=\"\" width=\"1892\" height=\"94\" srcset=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-080105.png 1892w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-080105-300x15.png 300w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-080105-768x38.png 768w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-080105-1536x76.png 1536w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-080105-18x1.png 18w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-080105-700x35.png 700w\" sizes=\"auto, (max-width: 1892px) 100vw, 1892px\" \/><\/a><\/p>\n<h4>What actually broke: Workers + CSP + WASM in Forge<\/h4>\n<p>Tesseract.js runs OCR inside a Web Worker. That\u2019s the correct architecture (OCR is heavy, keep it off the main thread).<\/p>\n<p>The surprise: in Forge Custom UI, a worker loaded as a &#8220;normal&#8221; script can behave like it has its own, stricter sandbox rules.<\/p>\n<p>If that worker can\u2019t do what the WASM runtime needs, you get the kind of failures that look like &#8220;WASM is broken&#8221;, when in reality it\u2019s <strong>CSP and worker context<\/strong>.<\/p>\n<p>The fix wasn\u2019t &#8220;try another bundler&#8221; or &#8220;sprinkle more polyfills&#8221;. It was understanding that I needed the worker to execute under the <strong>same CSP as the main app<\/strong>.<\/p>\n<h4>The fix: Blob workers that inherit the main thread CSP<\/h4>\n<p>The core idea:<\/p>\n<ul>\n<li>Create the worker via a <strong>Blob URL<\/strong>, so it <strong>inherits the main thread CSP<\/strong>.<\/li>\n<li>Explicitly allow what OCR needs in the Forge manifest.<\/li>\n<\/ul>\n<p>And it boils down to this:<\/p>\n<pre><code class=\"language-js\">const worker = await createWorker(validLanguage, 1, {\r\n  workerPath: urls.workerPath,\r\n  corePath: urls.corePath + 'tesseract-core.wasm.js',\r\n  langPath: urls.langPath,\r\n\r\n  \/\/ The whole trick:\r\n  workerBlobURL: true,\r\n});<\/code><\/pre>\n<p>On the platform side, the app\u2019s <code>manifest.yml<\/code> CSP allows:<\/p>\n<ul>\n<li><code>scripts: 'unsafe-eval'<\/code> (needed for this WASM\/Tesseract setup)<\/li>\n<li><code>scripts: 'blob:'<\/code> (needed for Blob Worker URLs)<\/li>\n<\/ul>\n<p>This is the part that makes the whole feature go from &#8220;works in tunnel&#8221; to &#8220;works in Cloud&#8221;.<\/p>\n<p><a href=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-090758.png\" data-lbwps-width=\"1117\" data-lbwps-height=\"725\" data-lbwps-srcsmall=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-090758-18x12.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-1623\" src=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-090758.png\" alt=\"\" width=\"1117\" height=\"725\" srcset=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-090758.png 1117w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-090758-300x195.png 300w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-090758-768x498.png 768w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-090758-18x12.png 18w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-02-090758-700x454.png 700w\" sizes=\"auto, (max-width: 1117px) 100vw, 1117px\" \/><\/a><\/p>\n<hr \/>\n<h4><a href=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103248.png\" data-lbwps-width=\"279\" data-lbwps-height=\"643\" data-lbwps-srcsmall=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103248-5x12.png\"><img loading=\"lazy\" decoding=\"async\" class=\"alignright wp-image-1624\" src=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103248.png\" alt=\"\" width=\"192\" height=\"426\" \/><\/a>Bonus reality check: your OCR assets can\u2019t depend on the internet<\/h4>\n<p>I also didn\u2019t want OCR to download anything from random URLs at runtime.<\/p>\n<p>So the app ships the whole OCR stack as static assets:<\/p>\n<ul>\n<li><code>public\/ocr\/worker.min.js<\/code><\/li>\n<li><code>public\/ocr\/core\/tesseract-core.wasm.js<\/code> (+ wasm variants)<\/li>\n<li><code>public\/ocr\/lang-data\/*.traineddata.gz<\/code><\/li>\n<\/ul>\n<p>That means OCR works in production with no &#8220;call home&#8221; behavior.<\/p>\n<p>Yes, it\u2019s heavier. But it\u2019s predictable.<\/p>\n<h4>Safety: guardrails so OCR doesn\u2019t nuke the browser tab<\/h4>\n<p>Client-side OCR is one of those features that will happily eat RAM for breakfast if you don\u2019t put it on a leash.<\/p>\n<p>This implementation is pretty strict:<\/p>\n<ul>\n<li>Only accepts <code>data:<\/code> image URIs, <code>blob:<\/code> URLs, or same-origin URLs.<\/li>\n<li>Caps Data URI length to ~25MB.<\/li>\n<li>Downscales images bigger than <strong>4000px<\/strong> (canvas) before OCR.<\/li>\n<li>Hard timeout at <strong>2 minutes<\/strong>.<\/li>\n<\/ul>\n<p>That\u2019s not &#8220;security theater&#8221; &#8211; it\u2019s the difference between &#8220;neat feature&#8221; and &#8220;my Jira tab froze again&#8221;.<\/p>\n<h4>The result<\/h4>\n<p>Now the production flow is actually boring (which is what you want):<\/p>\n<ol>\n<li>Click <strong>Scan Text<\/strong>.<\/li>\n<li>The browser loads the bundled worker\/WASM\/language data from the Forge static resource.<\/li>\n<li>A <strong>Blob worker<\/strong> starts (inherits CSP).<\/li>\n<li>OCR runs.<\/li>\n<li>Text appears.<\/li>\n<\/ol>\n<p><strong>No screenshot leaves the user\u2019s browser.<\/strong><\/p>\n<p>And yes &#8211; the service prints a loud console banner before any scary warnings:<\/p>\n<p><a href=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103853.png\" data-lbwps-width=\"1896\" data-lbwps-height=\"132\" data-lbwps-srcsmall=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103853-18x1.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-1631\" src=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103853.png\" alt=\"\" width=\"1896\" height=\"132\" srcset=\"https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103853.png 1896w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103853-300x21.png 300w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103853-768x53.png 768w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103853-1536x107.png 1536w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103853-18x1.png 18w, https:\/\/www.drinkits.lv\/wp-content\/uploads\/2026\/02\/Ekranuznemums-2026-02-09-103853-700x49.png 700w\" sizes=\"auto, (max-width: 1896px) 100vw, 1896px\" \/><\/a><\/p>\n<p>Because if you\u2019re going to fight CSP for a living, you deserve a tiny victory flag.<\/p>\n<h4>Takeaway<\/h4>\n<p>If you\u2019re building anything spicy in Forge Custom UI (WASM, workers, heavy client runtimes), treat <code>forge tunnel<\/code> as <em>a convenience<\/em>, not a guarantee.<\/p>\n<p>The only environment that matters is the one your customers run: <strong>production Jira Cloud<\/strong>, with real CSP and real asset hosting.<\/p>\n<p><strong class=\"ng-star-inserted\"><span class=\"ng-star-inserted\">Want to try it?<\/span><\/strong><\/p>\n<p>Check out <a href=\"https:\/\/marketplace.atlassian.com\/apps\/2464899201\/attachment-architect\">Attachment Architect on the Atlassian Marketplace<\/a>. It now reads text, previews Excel files, and lets you peek inside ZIPs &#8211; all securely inside Jira.<\/p>","protected":false},"excerpt":{"rendered":"<a href=\"https:\/\/www.drinkits.lv\/en\/2026\/02\/09\/hacking-the-forge-csp-how-i-got-client-side-ocr-running-in-jira-cloud\/\" rel=\"bookmark\" title=\"Permalink to Hacking the Forge CSP: how I got client-side OCR running in Jira Cloud\"><p>It\u2019s 2026. We have AI that can generate video from text, yet when a QA engineer attaches a screenshot of a log file to a Jira ticket, I still find myself manually re-typing error codes like it\u2019s 1995. So I built it into my Forge app Attachment Architect: a Scan Text button that turns pixels [&hellip;]<\/p>\n<\/a>","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_monsterinsights_skip_tracking":false,"_monsterinsights_sitenote_active":false,"_monsterinsights_sitenote_note":"","_monsterinsights_sitenote_category":0,"footnotes":""},"categories":[245],"tags":[266,289,288,33,287,249,283,286,290,285,284],"class_list":{"0":"post-1607","1":"post","2":"type-post","3":"status-publish","4":"format-standard","6":"category-jira","7":"tag-atlassian-forge","8":"tag-csp","9":"tag-custom-ui","10":"tag-devops","11":"tag-engineering","12":"tag-jira-cloud","13":"tag-ocr","14":"tag-react","15":"tag-tesseract","16":"tag-tesseractjs","17":"tag-webassembly","18":"h-entry","19":"hentry"},"_links":{"self":[{"href":"https:\/\/www.drinkits.lv\/en\/wp-json\/wp\/v2\/posts\/1607","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.drinkits.lv\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.drinkits.lv\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.drinkits.lv\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.drinkits.lv\/en\/wp-json\/wp\/v2\/comments?post=1607"}],"version-history":[{"count":18,"href":"https:\/\/www.drinkits.lv\/en\/wp-json\/wp\/v2\/posts\/1607\/revisions"}],"predecessor-version":[{"id":1636,"href":"https:\/\/www.drinkits.lv\/en\/wp-json\/wp\/v2\/posts\/1607\/revisions\/1636"}],"wp:attachment":[{"href":"https:\/\/www.drinkits.lv\/en\/wp-json\/wp\/v2\/media?parent=1607"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.drinkits.lv\/en\/wp-json\/wp\/v2\/categories?post=1607"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.drinkits.lv\/en\/wp-json\/wp\/v2\/tags?post=1607"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}