Changelog
All notable changes to Elyra Sjá are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
Planned
- Shared team database — point every seat on a Studio (team) license at one database, so projects, history, trends and monitors are shared across the team instead of living per-machine. The database location becomes configurable, with a server-backed option for safe concurrent multi-user access. The backend choice is deliberately still open: it decides the migration path, so it is being settled before any schema work lands.
- Search Console search analytics (clicks, impressions, CTR and position per
query/page), cached locally in a
search_metricstable that carriessource,countryanddevicefrom the start — so Bing Webmaster Tools drops into the same table as an equal source rather than a bolt-on, and so a worldwide site's positions aren't averaged into a number that describes nothing. Then URL Inspection for Google's own indexing verdict, and IndexNow as an explicit action under Get indexed. - Crawl-level
hreflangreturn-link reciprocity, using the annotations now stored per page. - Per-monitor locale (needs a
monitorsmigration, held back with the database decision). - Competitor comparison.
0.6.0 — 2026-09-06
GEO in three layers. Until now the GEO category was four whole-page checks that assumed an answer engine had fetched the page, read all of it, and would quote whatever it liked. This release builds the category out along the path a page actually travels: access (may the answer crawlers fetch it, and does the server let them when they ask), content (what the engine extracts, where the reading budget ends, which passages it could quote, whether the structured data, publisher entity and dates hold up), and outcome (do ChatGPT, Claude and Perplexity cite the page for a real question, did the crawlers actually come and keep coming, and were they who they said they were). GEO grows from four checks to fifteen and now decides roughly half the score under Improve AI visibility, which is where a fresh install starts.
Upgrading: installs itself if you are on 0.4.2 or later. Two migrations (
citations,bot_visits) run on first launch. Things to know:
- Scores change. Pages the crawlers can reach and read go up; pages behind a bot-blocking CDN, a JavaScript shell, incomplete schema or a missing publisher entity go down. Compare runs across the upgrade with that in mind.
- The citation probe needs API keys, one per engine, under Citation probe in Settings; an empty OpenAI or Anthropic key falls back to the AI agent's key. A probe sends the question you type to the engines you enabled and costs a few cents per engine on your own account.
- Verify addresses in the AI crawlers view downloads the operators' published IP lists and caches them locally; nothing from your log is sent.
- New installs start on Improve AI visibility. A saved default recipe is kept.
Added
-
AI crawler visits as a time series. The log analysis now keeps visits per crawler per day (
BotLogReport.daily, from the log's own timestamps viadates::parse_clf), shows a sparkline per crawler for the days the file covers, and — when a project is selected — stores the series in the newbot_visitstable (migration 0010), keyed per log file so two servers' logs add up while re-analyzing the same file replaces its days. Trends shows crawler visits per day across every log analyzed for the project, quiet days as zero. "Did ClaudeBot come back after we changed robots.txt?" is now a chart, not a guess. -
Verified crawler addresses. A
User-Agentis a string anyone can send. Verify addresses in the AI crawlers view compares the log's client addresses with the ranges each operator publishes (src/iprange.rs: OpenAI's three crawlers, Googlebot, Bingbot, PerplexityBot, Perplexity-User, Applebot) and reports per crawler whether every visit came from those ranges, some did (mixed, with the count and a few offending addresses) or none did (spoofed). Operators that publish no list — Anthropic among them — areunverifiable, never verified; a list that cannot be fetched isunavailable. Lists are cached under Application Support for a week; no address from the log leaves the machine. CIDR matching (v4, v6 and v4-mapped) is done without a dependency and pinned by tests. -
Cited by answer engines? The citation probe measures the outcome. Everything else in GEO predicts; this asks. From a page's report, type the question a user would ask and probe:
src/citations.rscalls ChatGPT (Responses API with theweb_searchtool), Claude (Messages API withweb_search_20260209, andfallbacks: "default"so a declined request is re-run server-side rather than returned as a refusal) and Perplexity (chat completions) with the user's own keys, concurrently, and judges each answer: does it cite the page's site (apex andwww.are one site), at which position among the cited sources, and who was cited instead. Parsing is pinned by tests against the documented response shapes, including the search-error and refusal cases; Perplexity's endpoint and every model id are settings, because those surfaces move.Every probe is a row in the new
citationstable (migration 0009) — failures included, so a gap in the history is visible as a gap. The report shows the results and the page's history; Trends shows the latest verdict per page and engine with the earlier ones as dots (citation_summary). Settings gains a Citation probe section with one key per engine (Keychain), falling back to the AI agent's key for the same provider so nothing is pasted twice. The UI says what a probe is — one sample of a non-deterministic system, through APIs that are close to but not identical with the consumer products — and what to read: the trend and the competitor list. -
Structured data read for what an engine needs, not counted.
src/schema.rswalks the JSON-LD (including@graphand arrays of types) and reports what the old@typecount could not:- Validity: a block that is not valid JSON is a fail that says so, instead of reading as "no structured data" — same outcome for the engine, different fix.
- Completeness (
schema_complete, 5): required properties per type (Article needsheadline, HowToname+step, Productname, Eventname+startDate+location, …) fail when missing; recommended ones (image,author,datePublished,dateModified, offers/rating/review …) warn. FAQPage and QAPage are checked question by question: a Question without anameor without an answertextis called out. - Publisher entity (
entity, 4): the Organization behind the page — name, canonicalurl,logo,sameAs— or the finding that only a WebSite, or nothing, is declared. Stored on the report so a crawl now lists every distinct Organization name the site uses with its page count, plus how many pages declare none (site report and PDF). - Dates that agree (
freshness, 4): schemadatePublished/dateModified,article:*meta, the first<time datetime>and theLast-Modifiedheader are collected (src/dates.rsdoes the calendar arithmetic without a crate). No declared dates is a weight-0 note, not a penalty. Declared dates are held to: modified before published fails; two modified dates that disagree, a future date, a date over two years old, or a non-ISO date warn.
-
llms.txtgraded by content (src/llmstxt.rs): the file needs a# Titleand at least one link to be usable; the check reports how many entries carry a description, and a new Listed in llms.txt check (llms_lists_page, 2) says whether the analyzed page is one of them. Fix prompts cover the four new ids. -
What the model sees: the page as an answer engine receives it. Engines do not read pages; they extract the main content, keep what fits a reading budget, and quote passages. Every report now carries that view (
src/extract.rs):- The extracted text in reading order, from
<main>/<article>/role=mainor the body, with navigation, footers, cookie banners, sidebars and forms removed the way a readability extractor removes them — a site header is chrome, an article's own header is not, and a class on<body>never turns the whole page into chrome. The report says how many visible words were discarded as boilerplate. - A token budget across it: a marker at ≈2 000 tokens (≈4 characters each, stated as an estimate), and every block knows whether it starts inside it. Question headings are tagged.
- Passages judged as quotes, the five best kept with a 0–100 score and the reasons on both sides: 40–90 words, stands alone (does not open with "This"/"It"/"However" or the Norwegian equivalents), sits directly under a heading (a question heading counts double), carries a figure or date, is a full sentence, starts inside the budget. The gaps are what a writer changes.
- Three GEO checks derived from it: Quotable passages (
quotable, 5), Answer within the reading budget (answer_position, 4) and Signal to noise (signal_noise, 3). - Shown in the report as a "What the model sees" panel with the passages and
an expandable reader view (quoted passages highlighted, budget line drawn,
blocks beyond it greyed); the PDF export lists the top three; the MCP server's
analyze_pageand the CLI's--jsoninclude the extraction summary and top passages, so an assistant edits the exact text the engine will see. Reports stored before this load unchanged (the new fields default to empty).
- The extracted text in reading order, from
-
GEO access layer: can the answer crawlers reach the page at all? Every GEO signal so far described a page the crawler was assumed to have fetched; the page report never said whether it could. Four checks, all in the
geocategory and all deterministic:- AI answer crawlers allowed (
ai_access, weight 10) — the page's own path tested againstrobots.txtfor OAI-SearchBot, ChatGPT-User, Claude-User and Perplexity-User, resolving the most specific user-agent group as the crawlers do. Blocking one of these is a fail: they fetch pages to answer questions now. Blocked training crawlers (GPTBot, ClaudeBot, CCBot …) are a weight-0 note (ai_training): a licensing choice, not a visibility problem. An unreadable robots.txt yields "unknown", never "allowed". - Reachable as an AI crawler (
ai_edge, weight 8) — the page is fetched a second time with OAI-SearchBot's real User-Agent.robots.txtis advice; this is behaviour, and it is the only way to see a CDN's "block AI bots" switch, a WAF rule or a bot challenge (Cloudflare's "Just a moment…" and thecf-mitigatedheader are recognised even on a 200). 401/403/451 fail, 429/503 warn, a challenge fails whatever the status. A page that is 403 for everyone says so as a note rather than blaming bots. During a crawl the probe runs once on the seed and stands for the origin, since CDN rules are not per page; the check says where it was probed. - Content without JavaScript (
js_dependence, weight 6) — AI crawlers fetch HTML and execute nothing. Fewer than 50 words in the HTML together with the fingerprints of a client-rendered app (an empty#root/#app/#__nextroot, a<noscript>plea, script files) is a fail; under 150 words behind an app root is a warning. Judged from the HTML alone, so it works in the CLI and the MCP server too. - Quotable (
snippet, weight 5) —nosnippetormax-snippet:0, in the meta tag or theX-Robots-Tagheader, means engines may show no text from the page and Google excludes it from AI Overviews. Anoai/noimageaidirective is reported as a neutral note: a deliberate opt-out.
New fix prompts cover all four, including the curl commands that verify an edge fix by fetching with each crawler's User-Agent.
- AI answer crawlers allowed (
Changed
- GEO carries the weight the product claims. The category grows from four checks (24 weight points) to up to fifteen (80), so under Improve AI visibility it now decides roughly half the score. Scores will move between 0.5.1 and this version, up for pages the crawlers can reach and read, down for pages behind a bot-blocking CDN or a JavaScript shell — which is the point.
- A fresh install starts on Improve AI visibility (recipe GEO readiness) instead of Full audit. A saved default recipe is untouched.
X-Robots-Tagis read. The Indexable check used to see only the meta tag, so a page set tonoindexby header passed. Both sources now count, directives scoped to a bot (googlebot: noindex) included, and the detail names the header when that is where the directive came from.- The crawler and the page report share one parsed
robots.txtper origin, fetched once withllms.txtand the edge probe; the crawl no longer fetches it twice. Robots::default()now means "no file": the derived default hadempty: false, so a 404 read as a robots.txt that had rules and happened to allow everything, and the MCProbots_checktext said "no*group" instead of "there is no robots.txt".
0.5.1 — 2026-09-06
A security follow-up to 0.5.0, from an internal review of the request path. The theme: the app fetches URLs it did not choose — redirect targets, a site's sitemap, whatever an assistant hands the MCP server — and the guard on where those requests may go covered one of the three cases. It now covers all three, and judges a host by where it resolves, not only by what the URL looks like. Alongside it, two things the review turned up in the fix agent: it could not overwrite an existing file at all, and the Anthropic model list offered ids the API rejects.
Upgrading: installs itself if you are on 0.4.2 or later. Three behaviour changes worth knowing:
- A hostname that resolves to a private address is now refused unless you typed that host yourself. Auditing
localhostor an intranet staging site from the launcher, a crawl seed, a monitor or the CLI works as before; a redirect from it to a different private name does not.- The MCP server refuses local and private targets by default. Put
ELYRA_SJA_ALLOW_PRIVATE=1in the client'senvblock to audit a dev server through an assistant.- A dotted or retired Anthropic model id in Settings is repaired on first launch. If you had picked a specific model, glance at Settings once.
Security
- Requests are vetted by where they resolve, not only by what they look like.
The SSRF guard only ran inside the redirect policy, so it never saw the first
request, never saw the sitemap URLs a site's
robots.txtnames, and — being a check on address literals — was bypassed by any hostname that resolves to a private address.src/http.rsnow installs a filtering DNS resolver on the shared client that drops answers in loopback, private, link-local, CGNAT and IPv6 unique-local ranges, and exposescheck_untrusted_urlfor the literal half (hyper connects to IP literals without consulting DNS). Hosts the user typed — the launcher URL, a crawl seed, a monitor, a project's site URL, the CLI argument — are registered withtrust_host_ofand exempt from the DNS filter, so auditinglocalhost:3000or an intranet staging site still works; only that host is exempt, a redirect from it to another private name is refused as before. - The MCP server refuses local and private targets. Its URLs come from an AI
assistant that may have been steered by a page it just read, so
analyze_pageandrobots_checkreject a local target before any fetch, with a message naming the opt-in: start the server withELYRA_SJA_ALLOW_PRIVATE=1to audit a dev server through an assistant. A test pins that a valid licence does not lift this. - Sitemap locations are vetted before they are fetched. A
robots.txtmay name any URL as its sitemap; the crawler now requires it to be on the site being crawled (apex andwww.count as the same site) and not a local address, for the<sitemapindex>children too. - Page text in fix prompts is marked as data. Titles, URLs and check details
taken from the analyzed page are quoted between
«and», stripped of control characters and the quote marks themselves, and capped, and the fix agent's instructions say never to follow instructions found in such text. The page being fixed is third-party content and the prompt drives an agent with write access. - The OAuth loopback page escapes the
errorparameter Google (or anything else reaching the port) sends back, instead of reflecting it as markup.
Fixed
- The Anthropic model list in Settings offered ids the API rejects. Six of eight
used dots where the API uses hyphens (
claude-opus-4.8forclaude-opus-4-8), and two named models retired in February 2026. The list now carries current ids (Opus 5, Sonnet 5, Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5), and a saved dotted or retired id is repaired on load and on save, so nobody is stuck with a 404 on every Run with AI. - The fix agent could not write files of any size. The model's output ceiling
was the framework default of 4096 tokens, and
write_filecarries the whole file as tool arguments, so a template of a few hundred lines ended the turn mid-call. The ceiling is now 16 000 tokens. - The fix agent could not overwrite an existing file at all. Resolving a path
that already exists re-joined an empty tail onto it, which appends a trailing
separator (
…/page.html/), and every write then failed with "Not a directory". Creating new files worked, so the sandbox tests never noticed; the new overwrite test does. Found while making writes atomic, below. - File writes are atomic. The sandbox writes to a temporary file beside the target and renames it into place, keeping an existing file's permissions, so an interrupted write can no longer leave a half-overwritten file. Generated files go through the same path.
- An expired licence read as an expired trial.
LicenseStatusgainsexpired(the code's signature verifies but its validity window has passed), and the lock screen and Settings now say the licence expired and needs renewing, rather than telling a paying customer their 3-day trial is over. - A damaged or locked database showed as an app that silently refuses to open. Startup panicked on connect and migrate, which from Finder is invisible. It now shows a native alert naming the database file and what to do, then exits.
- Sitemap discovery lost the sitemap on apex-to-www redirects. The same-host
filter used the typed seed's host before the redirect target was known, so a seed
of
example.comdropped everywww.example.comURL its sitemap listed and the crawl fell back to link discovery alone. Apex andwww.are now the same site. robots.txtrules are matched against the query string too.Disallow: /*?sort=is a common rule; the crawler androbots_checkmatched the path alone and walked straight through it.- Dev-only npm advisories (nanoid, postcss) resolved with
npm audit fix.
0.5.0 — 2026-07-27
The GEO release, and the first with entry points other than the window.
Until now every GEO signal in Sjá was a prediction about how an answer engine
would read a page. This release adds the two things that measure and act instead:
AI crawler log analysis (did the crawlers actually come, and does robots.txt
let them?) and generation of llms.txt and JSON-LD from real crawl data. It also
opens the engine to other tools: a headless CLI for CI budgets and an MCP
server so an AI assistant can call the analysis directly.
Upgrading: installs itself if you are on 0.4.2 or later; 0.4.1 and earlier need the DMG once. Nothing to migrate. Two notes:
- The new AI crawlers view reads a server log you point it at. The file is never copied or uploaded — access logs contain visitor IP addresses.
- The CLI and MCP server require a licence and read it from
ELYRA_SJA_LICENSE(or the Keychain on an activated machine). Neither opens a window, touches the database, or writes anything to disk.
Added
-
AI crawler log analysis (Sja-7) — the first GEO feature that measures instead of predicting. Point it at an nginx/Apache access log and it reports which AI crawlers actually visited: hits, distinct pages, status split, first/last seen, top paths, and each crawler's own
robots.txtverdict.That last part needed a real fix: the robots parser only ever read the
User-agent: *group, so a site that allows*and disallowsGPTBot— the most common way to be invisible to AI answer engines — was reported as "not blocked".src/robots.rsnow parses every group and resolves the most specific match, and the crawler itself obeys rules written for its own token rather than only*.The report separates answers crawlers (blocking them means absence from answers today) from training and search, marks
Google-Extended/Applebot-Extendedas robots-only tokens that never visit, and flags the contradiction when a blocked crawler shows up in the log anyway.Logs are read where they lie — never copied, never uploaded — streamed line by line, and the report states what share of lines it could parse rather than presenting a partly-read file as a quiet site. 20 000 lines parse in ~140 ms.
Changed
-
robots.txtis parsed in full. The crawler only ever read theUser-agent: *group;src/robots.rsnow parses every group and resolves the most specific matching token. Two consequences beyond the log analysis: the crawler obeys rules written for its own product token (ElyraSja) rather than only*, andCrawl-delayis read from the group that actually governs it. -
Generate
llms.txtand JSON-LD from crawl data (Sja-8). Sjá already flagged them as missing; now it writes them — deterministically, from pages that were actually crawled.llms.txtfollows the llmstxt.org shape and lists real URLs with their real titles and descriptions, grouped into sections. Error pages, untitled pages and anything marked noindex are excluded: advertising a page the site tells search engines to ignore would contradict the site itself. Output is stable across runs, so a diff shows content changes rather than reordering.FAQPage schema is built from the page's own question-style headings and the prose under them —
PageReportnow stores those Q&A pairs, the way it started storinghreflangalternates. A question with no answer beneath it is skipped, and a page with no Q&A generates nothing, with an explanation, instead of invented questions. Article schema emits nodatePublished, because the crawl never saw one.Saving goes through the same sandbox and confirmation dialog as the AI agent:
Sandbox::write_confirmedis now shared by both, so there is one write path rather than a quieter second one. The Run with AI prompts were rewritten to point at the generators and to forbid inventing fields. -
Headless mode for CI (Sja-11):
elyra-sja check <url> --budget 80. Exit codes are the interface — 0 budgets met, 1 a budget missed, 2 could not run — and 1 and 2 are deliberately distinct so a pipeline can tell a score regression from a broken site. Budgets work per category (--budget-geo 70), because a GEO regression otherwise hides behind a healthy overall score, and a budget on a category that was never measured fails rather than passes.It exits before a window, a database or the app data directory exists — asserted by a test that points
HOMEat an empty directory and checks nothing appears in it.--jsonoutput has its field names pinned by a test, since pipelines parse them. A signed licence is required, fromELYRA_SJA_LICENSEin CI or from the Keychain on a machine where the app is already activated. -
MCP server (Sja-12):
elyra-sja mcpspeaks JSON-RPC 2.0 over stdio, so Claude Desktop, Zed or Cursor can call the analysis engine while someone edits content — a model driving Sjá rather than the other way round. Two read-only tools:analyze_page(score plus every failing check with its evidence) androbots_check(whether a named AI crawler may fetch a URL, resolving the most specificrobots.txtgroup, and what that crawler is for).Nothing that writes files or spends an AI key is exposed — an external agent must not be able to edit a checkout through this door — and a test pins the tool list to exactly those two. Calls are rate limited to 30/minute, because Sjá fetches real pages and a looping assistant has no limiter of its own. A tool failure is returned as a result with
isError, not a JSON-RPC error, so the model adapts instead of the assistant reporting a crash. Diagnostics go to stderr: stdout is the protocol.Tested against the real binary over stdio — handshake, notification handling (a notification must get no response), tool listing, malformed input followed by a working request, and that nothing is written to disk.
0.4.4 — 2026-07-27
The audit follow-up: every finding from the external review that 0.4.3 didn't already fix, each reproduced with a test that fails against the previous version.
Upgrading: installs itself if you are on 0.4.2 or later. Two behaviour changes worth knowing:
- Site scores may rise on sites with broken links. Error pages are no longer averaged into the crawl score; they are still listed, and the header now reads "N crawled · M scored" when the two differ.
- Saving Settings can now report an error it previously swallowed — if the Keychain refuses a secret, you are told, and the value is kept in the local database rather than silently lost.
Release process
- Releases can now be built entirely locally (
scripts/release-local.sh). GitHub Actions has blocked a release twice — a flaky headless-Chrome step, then an Actions billing stop — and a security fix must not wait on an invoice. The script runs the same steps in the same order with the same gates: tests, codegen drift check, version agreement betweenCargo.tomlandelyra.toml, sign, DMG, notarize + staple, checksum after stapling, a zip of the signed bundle verified by re-expanding it, and an ed25519-signedlatest-v2.jsonplus the frozen legacy manifest. It refuses to produce an unsigned or unnotarized build: both are hard failures, because Gatekeeper rejects them on a customer's machine. After notarizing it verifies (stapler validateplusspctlon the DMG and the app) instead of assuming. A deliberate dry run (SKIP_NOTARY=1) writes no checksum and no manifest and moves its output todry-run/renamed-DO-NOT-PUBLISH, since a checksum plus a manifest is exactly what makes a directory look publishable. - CI got cheaper without losing coverage: documentation-only pushes are skipped,
superseded runs on the same ref are cancelled (the release workflow deliberately
is not — aborting mid-notarization would leave a half-published release), and both
workflows cache the Rust build. Moving CI to Linux was considered and rejected:
keyringusesapple-nativeand the framework needswebkit2gtk, so a Linux run would no longer test the platform that ships.
Security
- The trial can no longer be reset by deleting a file, and the clock can't be
wound back (Sja-13). The licence key was mirrored to the Keychain but the
trial anchor was not, so
DELETE FROM settings— or removing the database — granted a fresh 3-day grace period, indefinitely.first_runis now mirrored to the Keychain and the earliest anchor found wins, so clearing either store changes nothing. Licensing also seesmax(now, highest time ever observed): setting the clock back neither extends the trial nor revives an expired licence (moving it forward still works, and burns the trial faster).
Fixed
- A failed Keychain write no longer loses the licence key (Sja-14).
secrets::setdiscarded every error and returned(), whilesave_licenseblanked the key from the database copy unconditionally — so if the Keychain refused, the user saw "activated" and was unlicensed after restart.setnow reports failures; the key is kept in the database when the vault refuses, and activation surfaces a warning rather than a false success or a false failure. Same for the API keys in Settings, andgsc_disconnectnow says so when a refresh token could not actually be deleted. - Response bodies are decoded by their declared charset (Sja-15), from
Content-Type, else a<meta charset>sniff, else UTF-8. Assuming UTF-8 turned every ISO-8859-1, Shift_JIS or GBK page into mojibake: wrong titles, wrong word counts, and keyword matching that could not succeed on non-ASCII terms. An end-to-end test now serves a Latin-1 page and asserts both the title and the keyword match survive the fetch, decode and parse. - Error pages no longer drag down the site score (Sja-16). A 404 scores near zero in every category, so averaging them in lowered the score for a reason the report attributed elsewhere. Broken pages are still listed; they are simply not scored, and the report shows "N crawled · M scored" whenever the two differ. Duplicate-title detection also ignores them, so a site full of "404 Not Found" no longer invents a duplicate-content finding.
Crawl-delayis now a rate limit, not just a start-stagger (Sja-16). The delay spaced request starts while still keeping four requests in flight; a host asking to be crawled slowly now gets one request at a time.- Sitemap URLs are XML-decoded (Sja-16):
?a=1&b=2is fetched as?a=1&b=2instead of producing a 404 blamed on the site. Named and numeric entities are handled; a bare&is left alone. - Switching project no longer leaves the previous project's data on screen (Sja-17), and a slow analysis that finishes after you switch no longer hijacks the view — nor attaches its report to the new project, which would have pointed Run with AI at the wrong checkout.
- An unverifiable licence is no longer indistinguishable from a valid one
(Sja-19). A failed
license_status()left the app silently unlocked; it now retries once and, if that fails, keeps failing open (deliberate — a transient local error must not lock out a paying customer) while showing a banner that says the licence could not be verified.
0.4.3 — 2026-07-25
A security and crawl-correctness release, from verifying an external audit against the code rather than taking its findings on trust. Every claim below was reproduced with a test that fails against the previous version.
Upgrading: the first release installable by the in-app updater since it was repaired. 0.4.2 users get it automatically; anyone still on 0.4.0 or 0.4.1 must install from the DMG once (see 0.4.1 / 0.4.2).
Security
- AI sandbox escape through a dangling symlink (
fs_tools.rs).resolve()walked up to the deepest existing ancestor usingPath::exists(), which follows symlinks — so a symlink whose target did not exist reportedfalse, the walk stepped straight past it, and the returned path was handed tocreate_dir_all+fs::write, which follow it out of the project. A repo containingnotes.txt -> /tmp/anywherewas enough to write outside the checkout; combined with page content reaching the model, prompt injection could aim it. The existing test only covered symlinks with an existing target — exactly the case that was already caught. Nowsymlink_metadatais used for the ancestor walk, a dangling symlink is rejected outright, and the not-yet-existing tail is re-walked to reject a symlink appearing there too. Two regression tests, both verified to fail against the old code. - Redirects can no longer be chased into the local network (
http.rs). The app follows user-supplied URLs and up to 10 redirects; nothing stopped a hostile page from redirecting at127.0.0.1,192.168.x,169.254.169.254orfd00::/7and using the app to probe the LAN. The redirect policy now vets every hop against loopback, private, link-local, CGNAT, unique-local and IPv4-mapped ranges (pluslocalhost/.local) and stops rather than follows. A test drives a real redirect from one loopback port to another and asserts the second server is never contacted. Deliberately still allowed: a URL the user types themselves — auditinghttp://localhost:3000is a legitimate workflow, and there is no privilege boundary being crossed. Known limitation: only address literals are vetted; a hostname that resolves to a private address is not caught (that needs a custom DNS resolver).
Fixed
- A crawl of a domain that redirects to
wwwfound exactly one page (crawl.rs). The same-host filter was seeded from the requested URL, so afterexample.com→www.example.comevery internal link and every sitemap URL was rejected as off-site. The host is now adopted from the post-redirect URL of the first page. Verified against a real site:bbc.comwent from 1 page to 4. - One uncapped response read (
crawl.rs):discover_sitemapreadrobots.txtwithtext()instead ofread_text_capped, the only path that bypassed the 5 MB body cap. - A panic in HTML parsing no longer takes the analysis down (
seo.rs): thespawn_blockingjoin used.expect(), so ifscraperpanicked on pathological markup the whole command died (a crawl merely lost the page, silently). It is now reported as an error on the report. - The frozen legacy manifest was unparseable by the clients it protects. It
shipped
"platforms": {}, and the website re-encodes JSON before serving it — an empty object becomes[], which the updater cannot deserialize into a map. Pre- 0.4.1 builds therefore got a manifest error on every launch instead of a quiet "up to date". (No update was ever offered, so nothing was at risk of breaking.) The published file now carries a non-emptyplatformsobject whose only key isfrozen, so it parses, reports up-to-date, and still lists no artifact for any real platform.src/manifest_tests.rspins this against the shipped file, using the same updater code the app runs and simulating the server's JSON round-trip, for every version from 0.1.0 on.
0.4.2 — 2026-07-25
Completes the 0.4.1 fix. 0.4.1 replaced the destructive updater with a safe one — but the safe one refused every update, so it could never have delivered its own successor.
Upgrading from 0.4.1: install from the DMG once more. 0.4.1's updater cannot accept any update, including this one. From 0.4.2 on, auto-update works.
Fixed
- Auto-update rejected every update (framework 0.5.5). The bundle verification
called
codesign --verify --strict --quiet, andcodesignhas no--quietflag — it exited 2 with "unrecognized option" every time, so the updater concluded nothing was correctly signed. Nothing unsafe was ever installed, but no update could be installed either. Verification now reports codesign's own reason in the error, and is covered by a test asserting a correctly signed bundle is accepted — both freshly signed and after thedittoround-trip. The previous tests only checked that bad bundles were rejected, which a broken invocation satisfies trivially.
0.4.1 — 2026-07-25
A hotfix for a serious 0.4.0 regression: auto-updating broke the app.
If Elyra Sjá won't open after updating to 0.4.0 — macOS says "The application “Elyra Sjá” can't be opened." — download the DMG from elyracode.com/sja and install it over the broken copy. Your data is safe: projects, history, crawls, monitors and settings live in
~/Library/Application Support/Elyra Sjaand the Keychain, not in the app bundle. Nothing is lost.Everyone on 0.4.0 or earlier must install 0.4.1 from the DMG once. Those builds cannot auto-update safely, so they are no longer offered updates at all (see below). Auto-update works again from 0.4.1 on.
Fixed
- Auto-update no longer destroys the installation. A macOS code signature
seals
Info.plistand every file underContents/. The updater replaced only the executable inside the signed.appand left its.oldbackup next to it — breaking the seal twice — so Gatekeeper refused to launch the app afterwards with no in-app way to recover. Fixed in framework 0.5.4: the updater now detects that it is running inside a bundle and replaces the whole.app, expanded withditto(preserving extended attributes and the signature), verified withcodesign --verify --strict, required to carry the sameCFBundleIdentifier, swapped by an atomic rename with the outgoing copy kept outside the bundle and rolled back on failure, and relaunched throughopenso LaunchServices re-registers it. A bare-binary artifact offered to a bundled app is now refused with an explanation rather than applied. - The published DMG checksum was always wrong.
.sha256was computed when the DMG was built, butxcrun stapler staplerewrites the file during notarization, so the published sum could never match the published download — anyone verifying it would conclude the file had been tampered with. It is now taken after stapling, and contains only the basename instead of a build-machine path. - The DMG is now code-signed as well as notarized, so
spctl -a -t installcan assess it instead of reporting "no usable signature". - The crawler's
User-AgentreportedElyraSja/0.1regardless of the real version (it had been wrong since 0.2.0). Site owners whitelist by UA, so it now reports the actual package version.
Changed
- The update artifact is a zip of the signed
.app(Elyra-Sja-<version>-macos-aarch64.app.zip), not the bare executable. - The update manifest moved to
/sja/latest-v2.json. A pre-0.4.1 client can't be fixed remotely, so the old/sja/latest.jsonis frozen at version0.0.0(dist/legacy-latest.json): those builds now see "up to date" forever instead of bricking themselves on the next update. - Framework submodule bumped to 0.5.4.
0.4.0 — 2026-07-25
The international release. Sjá now understands sites that target more than one
language or market: a new International category that validates hreflang,
and a market locale that reaches the actual request instead of decorating the
UI. It also lands the first slice of the Google Search Console integration and
a Stop button for crawls.
Upgrading: no database migration, and stored reports keep working. Two behaviour changes worth knowing:
- The Region picker is gone — it was wired to nothing. It is replaced by Language + Market, sent as
Accept-Language. The default is As served (no header, page analyzed exactly as returned), so analyses behave as before until you choose a market.default_regionis replaced bydefault_locale; an old saved value is ignored.- A category that measured nothing is no longer scored 100. If PageSpeed is enabled and fails, Performance is now absent rather than perfect. Overall scores are unchanged.
Added
- International targeting: a new
hreflangcategory. Multi-market sites are core work for the agencies this app is built for, and misconfiguredhreflangfails silently — search engines discard the whole annotation set without surfacing an error. The engine now checks, from the page alone: tag syntax (BCP 47; catchesen_US,english,en-GBR, and gives specific advice foren-UK→GBanden-EU, which look valid but aren't honoured), self-reference (a page missing from its own set invalidates it), absolute URLs, conflicting targets (one tag → two URLs),x-defaultonce two or more variants exist, canonical agreement (hreflang pointing at a non-canonical URL is ignored silently), and<html lang>vs hreflang disagreement. Thelangcheck now validates the tag instead of only checking that it exists. A single-market page is never penalized: it gets a neutral, zero-weight note. Annotations are stored per page, so crawl-level return-link reciprocity can be added later without re-analyzing anything. The AI Run with AI prompts cover each new finding. - Google Search Console: connection and property discovery (first slice of the
integration). Search Console has no API-key path and its scope is one Google
classifies as sensitive, so instead of shipping — and having to get verified — an
OAuth client of ours, you bring your own: create a Desktop-app credential in
your Google Cloud project, paste id and secret into Settings, and hit Connect
Google. The flow is the installed-app one Google recommends: PKCE (S256), a
loopback redirect on a
127.0.0.1port bound only for the duration of the flow, and a randomstatethat must match or the callback is refused. The refresh token is stored in the Keychain (the client secret too); access tokens are kept in memory and refreshed on demand, and a revoked grant is discarded so the app reports "not connected" rather than retrying a dead token forever. Show properties lists what the account can read, marks Domain vs URL-prefix properties, flags the one covering the current project, and greys out properties where you are an unverified user (they return no data). Rate limits, a disabled API and a revoked token each get an error message that says what to do. No ranking data is fetched yet, and nothing touches the score. - Search Console connections can be per project — a new Settings toggle, Connect a separate Google account per project, for agencies where every client has their own Google account. Off (the default) keeps one account for the whole app. The OAuth client is shared either way; only the sign-in differs, and each project's refresh token is stored under its own Keychain account, so one client's Google access is never reachable through another project. Switching the toggle deletes nothing — tokens stored under the other mode come back when you switch back. Connect/Disconnect and the property list act on the selected project, and connecting with no project selected is refused with an explanation rather than silently writing to the shared connection.
- Crawl cancellation from the UI — a Stop button next to Crawl halts an
in-flight crawl at its next checkpoint: no further pages are started, the
requests already in flight are aborted, and everything finished so far is
aggregated and saved as a normal crawl.
SiteReportcarries a newcancelledflag, which the overview surfaces as a stopped early badge so a partial crawl can't be mistaken for a complete one. Stopping before the first page completes stores nothing. Cancellation is scoped by a crawl generation, so a late cancel can never stop the next crawl, and the politeness delay is now slept in 100 ms slices so a Stop is honoured promptly even behind a longCrawl-delay.
Changed
analyze_page,analyze_and_saveandcrawl_sitetake alocaleargument, andPageReportcarries the locale it was analyzed as. Thedefault_regionsetting is replaced bydefault_locale; old saved values are ignored.
Fixed
- A category that measured nothing is no longer reported as a perfect score.
Categories built only from neutral
infochecks were scored 100 — so a page with nohreflangwould have claimed a flawless International score, and a failed PageSpeed run showed Performance 100 instead of "not measured". Such categories are now omitted. Overall scores are unaffected (infonever carried weight).
Documentation
- Features documents the market locale and the full
hreflangcheck list — including what is not checked yet: return-link reciprocity, andhreflangdelivered in HTTP headers or XML sitemaps. - Settings gains a four-step Search Console setup guide, the one-account vs per-project trade-off, and the locale defaults.
- Security explains why the app ships no Google credentials, how the OAuth flow
is bounded (loopback port open only for the duration,
stateverified, read-only scope), and that per-project tokens are isolated by Keychain account.
Internal
- The crawler's
User-Agentreports the real package version instead of a hardcoded0.1. Site owners whitelist by UA, so it should not lie.
0.3.0 — 2026-07-24
The licensing release. License codes are now ed25519-signed and verified offline against a public key embedded in the app, so the binary contains no secret that could be turned into a keygen.
Upgrading: codes minted by the previous checksum scheme no longer validate. If you hold an older
ELYRA-…code, get a replacement at elyracode.com/sja — the app will keep running on its trial grace period until you activate it.
Changed
- Licenses are now ed25519-signed (issued and signed server-side by
elyracode.com), replacing the offline FNV-1a checksum scheme. Codes look like
SJA-<base64url(payload)>.<base64url(signature)>and carry plan, licensee and expiry claims; the app embeds only the public key, so the binary contains no secret and cannot be turned into a keygen. Expiry is enforced (exp == 0means perpetual), issued-at is sanity-checked against the clock, and unknown payload versions fail closed. Codes are case-sensitive, so activation no longer upper-cases the input. Keys minted by the retired scheme stop validating.
Documentation
- Licensing rewritten around the signed format: the token layout, every claim
(
v/id/plan/email/iat/exp, withexp = 0meaning perpetual), how a code is accepted, and how to rotate the signing key. - Releasing documents publishing to elyracode.com with
deploy_sja: the notarization gate, versioned filenames, the checksum sidecar, and the signedlatest.jsonthat drives the in-app updater. - Corrected the architecture map, which still described the retired checksum validator.
0.2.0 — 2026-07-24
A performance, security, and hardening release. No breaking changes to the user-facing workflow; the crawler is substantially faster and the AI file sandbox is now symlink-safe.
Performance
- One shared, pooled HTTP client (
src/http.rs) instead of building a newreqwest::Client(and rustls config) per request — a crawl previously paid a fresh TLS handshake for every page and everyrobots.txt/llms.txtprobe. - Origin facts cached per crawl:
robots.txt/llms.txtare probed once per origin instead of once per page (was 2 extra requests × every page). - Bounded-concurrency crawl (4 in flight, staggered starts) replaces the fully sequential loop with a fixed 250 ms sleep between pages.
- HTML parsing moved to
spawn_blockingso large DOMs no longer block the async runtime (and progress events). - Response bodies are capped (5 MB) and non-text content types are rejected, so a large or binary URL can't be pulled into memory and parsed as HTML.
list_reportsno longer ships report payloads — history lists return metadata and fetch a full report on demand viaget_report.- Database indexes on
project_id(+ a partial index for unread alerts) and WAL journal mode, so monitor writes and UI reads don't block each other. - Release profile (
lto = "thin",codegen-units = 1,strip). - Crawl reports slimmed over IPC: full per-page reports now live in a
crawl_pagestable and are fetched only when the user drills into a page, so loading a 100-page crawl no longer ships every check for every page. - History retention + vacuum at startup: keeps the 20 newest runs per URL and the 20 newest crawls per project past 90 days, caps alerts at 500, drops orphan page rows, and vacuums only when there is real space to reclaim.
Changed
- Row mapping via
sqlx::FromRowinstead of hand-writtentry_get("col").unwrap_or_default()chains (~50 → 17 call sites, and the remainder are single-columnRETURNING/ JSON payload reads). Decoding now fails loudly on schema drift rather than silently defaulting.- Booleans keep an explicit conversion: SQLite stores them as INTEGER and the
sqlx
Anydriver refuses to decode that intobool, somonitors/alertsmap through private row structs with i64 flags. - Added an integration test that runs the migrations and exercises every list query, so a wrong column name or type fails in CI instead of at runtime.
- Booleans keep an explicit conversion: SQLite stores them as INTEGER and the
sqlx
main.rssplit into focused modules (1460 → ~130 lines):app_paths,settings,licensing,projects,reports,trends,crawls,monitors,ai,export. Verified the registered command set is unchanged (33 commands).
Security
- Sandbox escape via symlinks closed: the AI agent's file tools normalized
paths lexically only, so a symlink inside the project could point at e.g.
~/.ssh. Paths are now canonicalized (resolving symlinks) and checked against the canonical project root. - PageSpeed API key moved to the Keychain, matching the AI key (it was stored in the settings JSON in SQLite).
- Exported reports are written to an owner-only (
0700) directory under Application Support instead of the shared/tmp, and pruned after a day — they can contain client data. - Release CI refuses to publish an unsigned update manifest if
UPDATER_PRIVATE_KEYis missing (previously it emitted"signature": ""). - Dropped the deprecated
codesign --deep(single-binary app has no nested code).
Added
- Offline test suite (16 tests) covering HTML parsing/word count, scoring and
grading, recipe weighting, sitemap
<loc>extraction, URL dedupe keys, robots.txt matching, crawl aggregation, license validation, and the file sandbox (including symlink escape). - CI workflow running
cargo clippy --all-targets -D warnings+cargo teston every push and pull request.
Fixed
- Word count no longer includes
<script>/<style>, which inflated content depth and let JS-heavy pages pass the check on minified bundles. - robots.txt matching now supports
*wildcards,$anchors, andAllow:overrides (longest match wins), and honoursCrawl-delay. - AI write-confirmation could hang forever:
write_fileawaited the UI's answer with no timeout, so a closed window leftrun_fixstuck and leaked the pending oneshot. Now times out after 3 minutes and cleans up. - Overlapping monitor ticks: the frontend timer started a new tick even if the previous one was still running (duplicate runs/alerts). Added a guard, and the interval is now cleared on destroy.
- Crawl dedupe: post-redirect URLs weren't marked visited, so a page could be
analyzed twice and skew duplicate-title detection. URL keys now also normalize
directory indexes (
/index.html). - Sitemap discovery is truncated early instead of collecting every
<loc>(a sitemap can hold 50k entries) into memory.
0.1.1 — 2026-07-24
Fixed
- App failed to launch after download: the SQLite path and migrations directory
were baked in at compile time (
CARGO_MANIFEST_DIR), pointing at the build machine. The database now lives in~/Library/Application Support/Elyra Sja, and migrations are embedded in the binary and staged there at startup.
0.1.0 — 2026-07-24
First release. A standalone, local-first SEO/GEO optimization workspace built on the Elyra Framework (Rust + Svelte 5), for Apple Silicon macOS.
Added
- Single-page analysis — fetch + parse a URL into 19 weighted, categorized checks across Meta, Structure, Social, GEO/AI, and Indexing, with an overall score (0–100) and grade.
- Performance / Core Web Vitals — optional 6th category via Google PageSpeed Insights (LCP, CLS, TBT, FCP, performance score); opt-in in Settings.
- Site crawl — sitemap +
robots.txtdiscovery and internal-link BFS, with robots disallow respect, a politeness delay, broken-page detection, and duplicate title/description detection, rolled up into a site report. - Projects & history — projects (name, site URL, local code path) and a full analysis history stored in local SQLite.
- Trends & comparison — score-over-time per URL with sparklines, and a run-to-run diff of fixed vs regressed checks.
- AI fix agent — send an optimized fix prompt to your model (OpenAI /
Anthropic) via
elyra::ai; with a project's local path set, the agent edits the real files through sandboxed file tools, with an optional per-write confirmation dialog. - Fix-prompt builder — click any issue to append its context-aware, copy-paste-ready fix prompt to a prompt field.
- Monitors & scheduling — recurring re-scans (hourly/6h/daily/weekly) that feed history and raise regression / error alerts with native notifications.
- Client-ready PDF reports — branded, print-optimized reports for a single page and for a whole-site crawl.
- Settings — AI provider/model + API key, PageSpeed, analysis defaults, agency branding, write-confirmation toggle, and license activation (⌘,).
- Commercial licensing — a 3-day trial, then an offline-validated license code (issued at elyracode.com/sja). Secrets (API key, license) are stored in the macOS Keychain.
- Auto-updater — ed25519-verified updates with a startup check and a "Check for Updates…" menu item.
- Distribution —
.icnsgeneration, DMG packaging, and a GitHub Actions release pipeline (build → sign → notarize → DMG → signed update manifest). - Theme — Tokyo Night palette with a Svelte-orange brand accent; dark/light/ auto.