Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Release notes
Changelog
Elyra

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_metrics table that carries source, country and device from 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 hreflang return-link reciprocity, using the annotations now stored per page.
  • Per-monitor locale (needs a monitors migration, 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 via dates::parse_clf), shows a sparkline per crawler for the days the file covers, and — when a project is selected — stores the series in the new bot_visits table (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-Agent is 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 — are unverifiable, never verified; a list that cannot be fetched is unavailable. 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.rs calls ChatGPT (Responses API with the web_search tool), Claude (Messages API with web_search_20260209, and fallbacks: "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 and www. 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 citations table (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.rs walks the JSON-LD (including @graph and arrays of types) and reports what the old @type count 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 needs headline, HowTo name + step, Product name, Event name + 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 a name or without an answer text is called out.
    • Publisher entity (entity, 4): the Organization behind the page — name, canonical url, 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): schema datePublished/dateModified, article:* meta, the first <time datetime> and the Last-Modified header are collected (src/dates.rs does 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.txt graded by content (src/llmstxt.rs): the file needs a # Title and 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=main or 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_page and the CLI's --json include 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).
  • 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 geo category and all deterministic:

    • AI answer crawlers allowed (ai_access, weight 10) — the page's own path tested against robots.txt for 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.txt is 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 the cf-mitigated header 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/#__next root, 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) — nosnippet or max-snippet:0, in the meta tag or the X-Robots-Tag header, means engines may show no text from the page and Google excludes it from AI Overviews. A noai / noimageai directive 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.

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-Tag is read. The Indexable check used to see only the meta tag, so a page set to noindex by 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.txt per origin, fetched once with llms.txt and the edge probe; the crawl no longer fetches it twice.
  • Robots::default() now means "no file": the derived default had empty: false, so a 404 read as a robots.txt that had rules and happened to allow everything, and the MCP robots_check text 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 localhost or 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=1 in the client's env block 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.txt names, and — being a check on address literals — was bypassed by any hostname that resolves to a private address. src/http.rs now installs a filtering DNS resolver on the shared client that drops answers in loopback, private, link-local, CGNAT and IPv6 unique-local ranges, and exposes check_untrusted_url for 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 with trust_host_of and exempt from the DNS filter, so auditing localhost:3000 or 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_page and robots_check reject a local target before any fetch, with a message naming the opt-in: start the server with ELYRA_SJA_ALLOW_PRIVATE=1 to 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.txt may name any URL as its sitemap; the crawler now requires it to be on the site being crawled (apex and www. 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 error parameter 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.8 for claude-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_file carries 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. LicenseStatus gains expired (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.com dropped every www.example.com URL its sitemap listed and the crawl fell back to link discovery alone. Apex and www. are now the same site.
  • robots.txt rules are matched against the query string too. Disallow: /*?sort= is a common rule; the crawler and robots_check matched 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.txt verdict.

    That last part needed a real fix: the robots parser only ever read the User-agent: * group, so a site that allows * and disallows GPTBot — the most common way to be invisible to AI answer engines — was reported as "not blocked". src/robots.rs now 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-Extended as 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.txt is parsed in full. The crawler only ever read the User-agent: * group; src/robots.rs now 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 *, and Crawl-delay is read from the group that actually governs it.

  • Generate llms.txt and 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.txt follows 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 — PageReport now stores those Q&A pairs, the way it started storing hreflang alternates. 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 no datePublished, because the crawl never saw one.

    Saving goes through the same sandbox and confirmation dialog as the AI agent: Sandbox::write_confirmed is 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 HOME at an empty directory and checks nothing appears in it. --json output has its field names pinned by a test, since pipelines parse them. A signed licence is required, from ELYRA_SJA_LICENSE in CI or from the Keychain on a machine where the app is already activated.

  • MCP server (Sja-12): elyra-sja mcp speaks 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) and robots_check (whether a named AI crawler may fetch a URL, resolving the most specific robots.txt group, 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 between Cargo.toml and elyra.toml, sign, DMG, notarize + staple, checksum after stapling, a zip of the signed bundle verified by re-expanding it, and an ed25519-signed latest-v2.json plus 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 validate plus spctl on 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 to dry-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: keyring uses apple-native and the framework needs webkit2gtk, 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_run is now mirrored to the Keychain and the earliest anchor found wins, so clearing either store changes nothing. Licensing also sees max(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::set discarded every error and returned (), while save_license blanked the key from the database copy unconditionally — so if the Keychain refused, the user saw "activated" and was unlicensed after restart. set now 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, and gsc_disconnect now 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-delay is 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&amp;b=2 is fetched as ?a=1&b=2 instead 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 using Path::exists(), which follows symlinks — so a symlink whose target did not exist reported false, the walk stepped straight past it, and the returned path was handed to create_dir_all + fs::write, which follow it out of the project. A repo containing notes.txt -> /tmp/anywhere was 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. Now symlink_metadata is 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 at 127.0.0.1, 192.168.x, 169.254.169.254 or fd00::/7 and 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 (plus localhost/.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 — auditing http://localhost:3000 is 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 www found exactly one page (crawl.rs). The same-host filter was seeded from the requested URL, so after example.comwww.example.com every 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.com went from 1 page to 4.
  • One uncapped response read (crawl.rs): discover_sitemap read robots.txt with text() instead of read_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): the spawn_blocking join used .expect(), so if scraper panicked 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-empty platforms object whose only key is frozen, so it parses, reports up-to-date, and still lists no artifact for any real platform. src/manifest_tests.rs pins 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, and codesign has no --quiet flag — 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 the ditto round-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 Sja and 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.plist and every file under Contents/. The updater replaced only the executable inside the signed .app and left its .old backup 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 with ditto (preserving extended attributes and the signature), verified with codesign --verify --strict, required to carry the same CFBundleIdentifier, swapped by an atomic rename with the outgoing copy kept outside the bundle and rolled back on failure, and relaunched through open so 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. .sha256 was computed when the DMG was built, but xcrun stapler staple rewrites 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 install can assess it instead of reporting "no usable signature".
  • The crawler's User-Agent reported ElyraSja/0.1 regardless 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.json is frozen at version 0.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_region is replaced by default_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 hreflang category. Multi-market sites are core work for the agencies this app is built for, and misconfigured hreflang fails silently — search engines discard the whole annotation set without surfacing an error. The engine now checks, from the page alone: tag syntax (BCP 47; catches en_US, english, en-GBR, and gives specific advice for en-UKGB and en-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-default once two or more variants exist, canonical agreement (hreflang pointing at a non-canonical URL is ignored silently), and <html lang> vs hreflang disagreement. The lang check 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.1 port bound only for the duration of the flow, and a random state that 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. SiteReport carries a new cancelled flag, 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 long Crawl-delay.

Changed

  • analyze_page, analyze_and_save and crawl_site take a locale argument, and PageReport carries the locale it was analyzed as. The default_region setting is replaced by default_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 info checks were scored 100 — so a page with no hreflang would 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 (info never carried weight).

Documentation

  • Features documents the market locale and the full hreflang check list — including what is not checked yet: return-link reciprocity, and hreflang delivered 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, state verified, read-only scope), and that per-project tokens are isolated by Keychain account.

Internal

  • The crawler's User-Agent reports the real package version instead of a hardcoded 0.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 == 0 means 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, with exp = 0 meaning 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 signed latest.json that 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 new reqwest::Client (and rustls config) per request — a crawl previously paid a fresh TLS handshake for every page and every robots.txt / llms.txt probe.
  • Origin facts cached per crawl: robots.txt / llms.txt are 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_blocking so 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_reports no longer ships report payloads — history lists return metadata and fetch a full report on demand via get_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_pages table 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::FromRow instead of hand-written try_get("col").unwrap_or_default() chains (~50 → 17 call sites, and the remainder are single-column RETURNING / 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 Any driver refuses to decode that into bool, so monitors/alerts map 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.
  • main.rs split 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_KEY is 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 test on 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, and Allow: overrides (longest match wins), and honours Crawl-delay.
  • AI write-confirmation could hang forever: write_file awaited the UI's answer with no timeout, so a closed window left run_fix stuck 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.txt discovery 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.icns generation, 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.