Inventivus
Engineering mission control — a local-first Windows desktop application for
engineering calculators, machine architectures, native app overlays, version
control, and a built-in AI assistant named Athena.
Inventivus is an Electron app. A React + TypeScript SPA runs in the renderer, a
Fastify + SQLite backend runs as a child process of the Electron main process, and
everything — projects, simulation history, git repos, encrypted credentials — stays
on the user's machine. There is no hosted application server.
Changelog
v1.9.1 — 6 September 2026
Security and production-readiness release. Focus: making a payment
unconditionally provable, tightening the shipped attack surface, and keeping UI
state consistent across the whole app.
Critical: a payment is now provable even if activation never completed
v1.8.8 bound the buyer's GitHub account to their Stripe customer — but only when
/licence/claim succeeded, and that call runs on the customer's machine after a
inventivus:// deep link. Every way that hand-off could fail (app closed before
the browser redirected, protocol handler not registered, network dropped, or the
earlier activation bug) left a paying customer with no binding at all, whose
"Sign in with GitHub to restore" correctly reported no subscription found. The
only fix was hand-writing KV keys. Three independent changes close that gap:
- The GitHub identity is now written to permanent Stripe objects. Checkout
stamps it onto the subscription and customer (andclient_reference_id),
not just the short-lived Checkout Session. Stripe itself now holds the binding. - Provisioning happens on payment, not only on claim. The issuer acts on the
checkout.session.completedwebhook — server-to-server, so no customer-side
failure can skip it. Idempotent with the claim path; whichever lands first
wins. An unpaid session provisions nothing, and an existing Enterprise record
is never downgraded. - Restore falls back to Stripe when the local link is missing. If no KV link
exists, the issuer searches Stripe by GitHub identity and rebuilds the record.
Onlyactive/trialingsubscriptions qualify, so a cancelled plan is never
resurrected. Payment alone is now sufficient proof of purchase.
Security hardening
- The shipped renderer no longer permits
eval()or inline script. The CSP
was one literal<meta>tag carrying'unsafe-inline' 'unsafe-eval'in
script-src— required by Vite's dev server, and therefore shipped to every
install. Since Athena renders model output into that window, this was a live
concern. The policy is now injected per environment: dev keeps what HMR needs,
the packaged build shipsscript-src 'self'. Same fix for the HUD overlay,
which floats over other applications and carries the local API token.
object-src,frame-src,base-uriandform-actionare pinned closed. - The licence issuer is rate-limited. It is a public origin holding the
Stripe secret key and had no metering on any endpoint. Seat-granting
(/team/join), selling (/checkout) and every Stripe-amplifying path
(/licence/claim,/licence/refresh, the GitHub poll) are now bounded
per client address, withRetry-After. Stripe's own webhook is deliberately
exempt (it retries on non-2xx), and a KV failure fails open so a broken
limiter cannot take checkout down with it.
Consistency: state is shared across the app
- An open workspace stays open. It lived in
useStateon the project page,
so navigating to another tab and back showed "Select a Workspace" and looked
like the work had been closed. Active workspace and active project are now
persisted server-side in SQLite, so they survive navigation, a reload, and the
app being closed and reopened. Deliberately an allowlisted set of keys, not a
general key/value store.
Fixes
- Local-model crashes explain themselves. An Ollama GPU/CUDA failure used to
surface as a raw HTTP 500 dump quoting Windows' stack-buffer-overrun text
("could potentially allow a malicious user to gain control") — alarming,
misleading, and unactionable. Provider failures are now mapped to plain
language with a concrete next step (update drivers, restart Ollama, run a
smaller model, start Ollama, pull the model, check the API key), with the
technical detail kept separate. Applies to every AI path, not just chat. - One version number.
/api/healthand the About panel each hard-coded
0.1.0, so a release bumped the installer and left the app reporting the old
version. Both now derive frompackage.json. yarn verify:editionsadditionally asserts the packaged version, the baked-in
issuer URL and public key, and the strict CSP in both editions.
v1.8.8 — 6 September 2026
Critical: subscriptions now survive reinstalls, updates, and new machines
Previously a customer's Pro/Enterprise status lived only in a local licence
token. A reinstall, an app update that reset app-data, or moving to a new machine
wiped that token — and there was no way to prove ownership again, so paying
customers silently dropped to Free with no recourse. Fixed end to end:
- Durable proof-of-purchase via GitHub. Every purchase now binds the buyer's
GitHub account to their Stripe customer in the issuer (captured at checkout).
A new "Restore purchase — sign in with GitHub" control on the Upgrade page
re-grants the subscription on any machine, from the customer's GitHub account
alone — no local state required. One click, and the install is linked so future
updates/reinstalls self-restore. - Recovery for customers who paid earlier. Signing in with GitHub while a
licence is installed links that purchase to the account, so pre-existing
customers (including the first testers) become restorable too. - Cancellation alerts + faster propagation. The issuer now exposes a
signature-verified Stripe webhook (/webhooks/stripe). It updates the stored
subscription status the moment Stripe reports a change and posts a merchant
alert to a configured Slack/Discord webhook on cancellation — Stripe's own
emails only notify the customer, not the merchant. Entitlement is still decided
by live Stripe polling, so the webhook is never a single point of failure. - Setup steps:
issuer/SUBSCRIPTION-PERSISTENCE.md. - Added 9 issuer tests (solo restore, cancelled-subscription refusal, link-at-
token, GitHub rename, webhook signature + notify); 1725 tests total.
v1.8.7 — 6 September 2026
Critical: payment activation and subscription management
Payments now complete on customer machines. Previously a customer could pay
through Stripe Checkout and never receive their features, because the app was
never told the payment succeeded — and with no recorded licence, the "Manage
subscription" button never appeared, leaving customers unable to cancel. Root
cause and fix:
- Checkout return had no path back into the app. Stripe Checkout opens in the
customer's browser, so the app cannot see the redirect that carries the
session_idneeded to claim the licence. The issuer now serves a/return
landing page that hands the session id back to the app through the
inventivus://billingdeep link (with an on-screen activation code and copy
button as a manual fallback). The app claims the licence automatically on
receipt, installs the signed token, and switches to the paid tier. inventivus://links launched a second app instance instead of reaching the
running one. Added the Electron single-instance lock, without which the
second-instancehandler never fires on Windows — so a customer who had the
app open when they paid would spawn a colliding second process and lose the
deep link entirely. This was the difference between "payment activates" and
"payment silently lost" for the common case.- Subscription management/cancellation now works. "Manage subscription"
appears once a real licence is installed and opens the Stripe Customer Portal
via the issuer (authenticated by the licence token, so no local Stripe key is
needed). The portal's return link deep-linksinventivus://billing?refresh=1,
so a cancellation or plan change is reflected in the app within seconds rather
than on the next hourly refresh. - Added issuer tests for the
/return,/portal-return, and/cancelled
landing pages and for the checkoutsuccess_urlwiring (1716 tests total).
v1.8.6 — 6 September 2026
Bug fixes
Workspace creation
- Fixed EasyEDA Pro
.eprj(and.eprj2,.elib) files being rejected at workspace creation with "file type not supported". The extension whitelist inserver/utils/path-safety.tshad drifted out of sync with the app catalog and was missing every EasyEDA format plus ~40 other catalog extensions (.kicad_pro,.fcstd,.prjpcb,.sldprt, and more). Added a regression test that enumerates the full catalog so this class of bug cannot silently reappear.
Onboarding
- The AI setup step is now skippable. Users without Ollama or an API key can click "Skip for now" and configure AI later in Settings → AI Provider. The app opens normally in the meantime; document analysis returns a clear "no AI configured" message rather than failing silently.
- Added a collapsible step-by-step setup guide per provider (install commands, model pull commands, exact URLs, port numbers) shown inline on the AI step.
- Rewrote the "connection failed" advice into numbered, actionable troubleshooting steps for each provider.
Team / sharing
- Free-tier project-share cap could be bypassed by using the bulk share path (
POST /api/team/share/applywith noproject_id). Fixed: bulk path now pre-counts and enforces the cap. - Task delete (
DELETE /api/team/tasks/:id) had no authorisation — any team member could delete any task. Fixed: only the task creator or a team owner may delete. POST /api/team/events?month=had no format validation; an unvalidated value was used as a SQLLIKEoperand, returning all events. Fixed: reject anything not matchingYYYY-MM.- Optional fields on events and tasks (
description,time,assigned_to_member_id) were stored as empty strings instead ofNULL, breakingIS NULLqueries. Fixed to storeNULL. - Task unassign was impossible —
assigned_to_member_idonly accepted a string, notnull. Fixed: acceptsz.string().nullable(). - Post display-name fallback used the team name instead of
member_id. Fixed.
Payments / Stripe
customer.subscription.deletedwebhook clearedtierandstatusbut leftsubscription_idset. Subsequent subscription refresh and portal calls would then attempt to operate on the deleted subscription, returning 402/404 from Stripe. Fixed: null outsubscription_idon deletion.- Success page copy said "Pro subscription is active" for all plans, including Enterprise customers. Fixed to neutral wording.
Version control
- Data loss: renaming a workspace then running a scoped commit silently dropped the renamed workspace's manifest entry (matched by name instead of stable
repo_path), causingpruneOrphansto delete the file from the repository. Fixed: carry-forward matches byrepo_path. Added a regression test. POST /vcs/unstagereturned a raw 500 stack trace when called on a file that was never committed to HEAD. Fixed: returns 400 with the git error message.- Commit error detection in
VersionControl.tsxmatched(e as Error).message.includes('409')— any error message containing "409" would show "Nothing to commit" instead of the real error. Fixed toe instanceof ApiError && e.status === 409. stageAll()returned the count of all files with any status rather than files newly staged. Fixed.
Cross-domain checking (core feature)
disciplineOfreturned'other'for EDA, firmware, and CAD nodes not yet placed in a container, in three separate locations:drift.ts,consequence.ts, andinference.ts. This silently excluded real cross-discipline connections from drift reports, consequence assessments, and the inference brief sent to the AI model — the most critical output of the system. Fixed in all three with a detector-based fallback.- EasyEDA Pro document detector ID was hardcoded to
'easyeda-schematic'instead of'document-projection', breaking the provenance chain for.eprjfiles in drift detection. Fixed. - Support-part warnings were emitted twice in EasyEDA Pro document detection (once in
warnings, once viaomitted). Fixed. - Detection warnings were concatenated twice into the machine-links response, doubling every warning shown to the user. Fixed.
partStateswas not passed tocomputeStalenessin the/inferenceendpoint (unlike/staleness), so CAD parts from container documents were always shown as current to the AI model regardless of actual state. Fixed.- Inference test fixture
DRIFTwas missing requiredrevisedandconsequencesfields, causing tests to exercise the wrong struct shape. Fixed.
Production release — v1.9.1
The shipping build. Two Windows installers come out of one codebase:
| Artifact | Path | Who it is for |
|---|---|---|
Inventivus-Setup-1.9.1.exe |
inventivus/release/standard/ |
Customers. Free tier with paywall; Pro/Enterprise unlocked by a signed licence. |
Inventivus-Unlocked-Setup-1.9.1.exe |
inventivus/release/unlocked/ |
Internal. Same code with resolveEntitlement short-circuiting to Enterprise. |
Both install side by side and share one data directory, so switching editions
never migrates or orphans a database.
Release gate
cd inventivus
yarn typecheck # 5 tsconfig projects
yarn test # 1828 tests / 76 files
yarn package:both # build once, package twice
yarn verify:editions # 82 assertions against the packaged .asarverify:editions reads values back out of the shipped archive rather than
trusting the source tree, and it fails the release on a regression. Alongside the
edition and EULA checks it asserts every security fix listed below is present in
the artifact a customer actually runs.
Matching gotcha for anyone extending that script:
tscrewrites module
references, soapp.isPackagedships aselectron_1.app.isPackagedand
validateExecutable(x)as(0, launch_safety_1.validateExecutable)(x). Anchor
assertions on the argument or a distinctive string literal, never the callee
shape — a source-shaped pattern reports a false FAIL on a correct build, and the
inverse mistake would silently pass a broken paywall.
Known limitations in this build
- Unsigned. No code-signing certificate is configured, so Windows SmartScreen
warns on first run (More info → Run anyway). Verify with
Get-AuthenticodeSignature— it reportsNotSigned. Signing needs an EV or OV
certificate; nothing in the code has to change. - Real-time collaboration is not deployed.
partykit.jsonstill carries the
placeholder host andpartykit.inventivus.appdoes not resolve. This fails
safe: sharing, joining, cloning and syncing all work over GitHub, and
collaborators simply do not see each other's cursors. Deploying it is
npx partykit login && npx partykit deploy, then setting the real host.
Security model
The app takes live payments and opens files a collaborator can influence, so the
boundaries are worth stating explicitly.
What the app cannot do
-
It cannot mint a licence. Only the public key is embedded. Signing happens
in the Cloudflare Worker, which is the sole holder of the private key and the
Stripe secret. No customer install ever containssk_live_…— asserted by
verify:editions. -
It cannot be unlocked from the environment. Three switches decide
entitlement, and in a packaged build every one of them ignores the launching
shell:Variable Effect if trusted Gate INVENTIVUS_EDITIONunlocked→ all featuresread from resources/edition.jsonwhen packagedNODE_ENV=development→ INVENTIVUS_DEV=1→ Enterprise, no licence!app.isPackaged && …INVENTIVUS_LICENCE_PUBLIC_KEYverifies self-signed licences baked key wins when packaged The compiled backend is a plain Node process that can be run directly, so it
carries a second, independent gate:isDev()also requires
INVENTIVUS_PACKAGED !== '1', which the main process sets from
app.isPackaged.
Enforcement is server-side
Gated routes answer 402 regardless of what the renderer draws, and quantity
caps are checked on every create path — including POST /api/projects/join,
not just POST /api/projects. Returning a features map to the renderer is not
enforcement: /api/hud withholds the Pro-gated structure and problems
payloads rather than relying on the HUD to hide them.
Caps apply on creation only, so a user who drops from Pro keeps everything
they already made.
Team seats
Seat entitlement is authoritative on the issuer, which re-reads the roster and
the live Stripe subscription on every licence issue — a cancelled plan cannot be
kept alive by anything cached locally. The local SQLite team tables are a mirror
that grants nothing.
Seat holders receive enterprise tokens, so a tier check is not a sufficient
authorisation boundary: canAdministerTeam() additionally requires
team.role === 'owner', otherwise any member could mint unlimited further seats
out of one subscription.
Renderer and IPC
- Strict CSP in the packaged build —
script-src 'self', no'unsafe-inline',
no'unsafe-eval', injected per environment by a Vite plugin so the dev
server's relaxed policy cannot ship. Athena renders model output into that
window, which is why this matters. contextBridgeonly; nonodeIntegration.- Filesystem IPC runs through
PathGuard, which denies credential stores inside
the home tree — including browser profiles (Chrome, Edge, Brave, Chromium,
Firefox, Thunderbird), where saved passwords and session cookies live. - Both spawn sites validate the executable, so a
.bat/.ps1/.htaor UNC
connection path cannot turn "open a document" into running an interpreter. inventivus://deep links are host-allowlisted, not merely shape-checked;
a link from any web page cannot point live sync at a foreign server.- Data pulled from a shared repository is confined on the destination, not
only the source. A crafted.inventivus/workspaces.jsoncannot write outside
its own folder — the previous behaviour allowed a write into the Windows
Startup folder on the victim's nextgit pull.
Issuer worker
- Licence tokens are signature-verified before their subject is used. A
Stripe customer id alone is not a credential — it is a username, and it appears
in URLs and support tickets. - Expiry is deliberately not checked on
/licence/refresh: renewing an expired
token is what that endpoint is for. Freshness comes from KV plus a live Stripe
check. - Stripe webhooks are HMAC-verified with a constant-time compare and a 300-second
replay window. The webhook is exempt from rate limiting (Stripe retries on
non-2xx); everything else is metered per IP on unspoofableCF-Connecting-IP,
failing open so a KV outage cannot stop customers refreshing. GET /notify/statusreports whether merchant alerts are landing, and
POST /notify/testsends one and returns the true HTTP status. These exist
because the alert path is silent by design and a mismatched webhook secret is
otherwise unobservable — Stripe'spending_webhooks: 0reads the same whether
an endpoint accepted the event or no endpoint was subscribed at all.
Repository layout
.
├── inventivus/ ← the actual product. Everything below this line is here.
├── memory/PRD.md Original product requirements and backlog
├── backend/ Emergent scaffold (FastAPI "Hello World" + Mongo) — unused
├── frontend/ Emergent scaffold (CRA + shadcn/ui starter) — unused
├── tests/ Empty pytest package left by the scaffold
└── test_result.md Scaffold testing-protocol file
backend/, frontend/, and tests/ are leftovers from the project generator. They
are not wired into the desktop app and can be ignored (or deleted) — the Fastify
backend the app actually runs is inventivus/server/.
Quick start
Requires Node 20+ and Yarn 1.x.
cd inventivus
yarn install
yarn devyarn dev builds the server, then runs the Vite dev server on :5273, a
TypeScript build of the Electron main + preload into dist-electron/, and
Electron itself — which spawns the Fastify backend on a free port.
yarn devrebuilds the server first for a reason: without it, a change to
server/has no effect on the running app because Electron spawns the last
compileddist-server/bundle. If a backend fix seems to do nothing, compare
stat server/<file>.tsagainststat dist-server/<file>.js.
First launch opens the onboarding wizard. To skip the paywall while developing:
yarn dev:unlockedFull setup walkthrough, including where each Stripe key goes:
inventivus/SETUP.md.
Deeper reference on architecture and internals:
inventivus/README.md.
Stack
| Layer | Tech |
|---|---|
| Shell | Electron 43 (contextBridge IPC, no nodeIntegration) |
| Renderer | React 18 · TypeScript · Vite 6 · Tailwind CSS |
| State | Zustand (global) · TanStack Query (server) |
| Graph / canvas | React Flow |
| Backend | Fastify 5, spawned as a child process on launch |
| Storage | SQLite via better-sqlite3 (WAL + FTS5 full-text search) |
| Version control | isomorphic-git — real git repos per project |
| Collaboration | Yjs + PartyKit (y-partykit) |
| Native interop | koffi FFI for Win32 window tracking |
| AI | Ollama · LM Studio · OpenAI-compatible · Anthropic |
| Payments | Stripe Node SDK + Ed25519-signed licence tokens |
| Packaging | electron-builder → Windows NSIS installer |
What's in the app
- Mission — dashboard and telemetry across projects.
- Calculators — truss FEM, beam, buckling, and co-simulation, with sandboxed
controller expressions and live charting. - Machines — visual architecture layout wired to simulation runs.
- Integrations — register any executable (CAD tool, simulator, terminal),
launch it, and park a frameless HUD window in front of it. The HUD is a genuine
second window owned by the launched app, not a reparented overlay — see
SETUP.md § "The HUD is not an overlay". - Version control — git plus entity-level history, mirrored to a workspace.
- Athena — full-page chat and a floating dock, SSE-streamed from the local
backend so API keys never reach the renderer. - Projects — CRUD, invites, join flow, status control, GitHub sign-in via
device flow. - Global search — SQLite FTS5 behind a Ctrl/⌘+K
command palette.
Editions and the free/Pro split
Two builds ship from one codebase with a single branch taken:
- Standard — the paywalled build customers install.
- Unlocked — same code,
resolveEntitlementshort-circuits to Enterprise.
Enterprise rather than Pro so "every feature on" keeps meaning that as tiers
grow.
What each tier gets
| Free | Pro | Enterprise | |
|---|---|---|---|
| Projects | 5 | 25 | unlimited |
| Integrations | 3 | unlimited | unlimited |
Calculators, Machines (simulations, workflows) |
— | ✓ | ✓ |
| HUD live help, project structure, focus tools | — | ✓ | ✓ |
| Version control, Athena, global search | ✓ | ✓ | ✓ |
| Joining a team | ✓ | ✓ | ✓ |
| Creating and running a team | — | — | ✓ (up to 35 seats) |
Version control, collaboration and Athena are free on purpose: a lapsed
subscription must never appear to have eaten someone's work. Limits are enforced
on creation only, so a user who drops from Pro keeps everything they have.
Joining a team is free by design — a member has to be able to accept the seat
somebody already paid for, which cannot happen if joining itself sits behind the
tier the seat grants. Their licence is then issued as Enterprise for as long as
the team's subscription is live.
Pro is proved by an Ed25519-signed licence token minted by the issuer worker
— never by a local database column. Writing tier = 'enterprise' into SQLite by
hand grants nothing, because resolveEntitlement reads only the token. See
Security model for the full boundary.
Building the installers
cd inventivus
yarn package:standard # → release/standard/Inventivus-Setup-1.9.1.exe
yarn package:unlocked # → release/unlocked/Inventivus-Unlocked-Setup-1.9.1.exe
yarn package:both # build once, package twice
yarn package:dir # unpacked build, faster for testingThe installer filename tracks version in inventivus/package.json, which is
also the single source for /api/health and the Settings About panel.
electron-builder rewrites
package.jsonin place to apply per-edition
metadata, and does not always restore it — leaving a tracked file with
scriptsanddevDependenciesstripped, so the nextyarn typecheckfails
with "Command not found". Recover withgit checkout -- package.jsonand
re-apply the version bump.verify:editionsasserts the rewrite did not
survive, so run it after packaging.
Scripts reference
yarn dev # Vite (:5273) + Electron + live backend
yarn dev:unlocked # same, with Enterprise entitlements
yarn build # renderer + electron main + server
yarn typecheck # tsc --noEmit across all 5 projects
yarn lint # typecheck && eslint
yarn test # vitest run (1828 tests)
yarn test:watch # vitest, watching
yarn start:server # backend only, for debugging
yarn verify:editions # 82 assertions against the packaged .asar
yarn verify:persistence # assert userData survives reinstall
yarn verify:webhooks # sign synthetic Stripe events → SQLite
yarn verify:pathb # polling-based subscription refresh
yarn verify:ai # AI provider proxy round-trip
yarn issuer:dev # local Cloudflare Worker on :8788
yarn issuer:keygen # generate an Ed25519 licence keypair
yarn issuer:secrets # push secrets to Cloudflare
yarn issuer:deploy # wrangler deploy
yarn issuer:tail # live worker logs
yarn issuer:test # vitest run issuer (81 tests)Unit tests are colocated as *.test.ts next to their subjects and run with Vitest
(vitest.config.ts), covering electron/, server/, src/, issuer/ and
partykit/.
Where data lives
| Path | Contents |
|---|---|
<userData>/inventivus.db |
SQLite database (WAL + FTS5) |
<userData>/config.enc |
Stripe + AI + GitHub credentials, AES-256-GCM |
<userData>/.inventivus.key |
32-byte key generated on first launch |
<userData>/repos/project-<id>/ |
per-project git repositories |
<userData> is %APPDATA%/inventivus/ on Windows — lowercase, and identical
for both editions, because app.setName(DATA_IDENTITY) pins it. Changing that
string is a silent data migration, which is why verify:editions asserts it.
The key file is written 0600, which is close to meaningless on Windows: treat
<userData> as readable by anything running as that user. The encryption
protects the credentials at rest against copying the file elsewhere, not against
the local user.
Further reading
| Document | Covers |
|---|---|
| inventivus/SETUP.md | First-time setup, keys, HUD, licensing, pitfalls |
| inventivus/README.md | Architecture and module-level reference |
| inventivus/issuer/README.md | Deploying the licence issuer (~15 min) |
| INFRASTRUCTURE.md | Live infrastructure: issuer, Stripe wiring, secrets |
| VERIFICATION_CHECKLIST.md | Manual pre-release checklist |
| memory/PRD.md | Original requirements and backlog |
| FILE_OPERATIONS_FIX.md | File open/save handling notes |
Support
Licensing or billing problems — including a payment that did not activate — go to
inventivustech@gmail.com. A customer whose subscription is not recognised
should sign in with GitHub from the banner at the top of the app, or from
Upgrade → Restore purchase; that reconciles against Stripe directly and does
not depend on the original activation having completed.
License
Proprietary. © Inventivus. All rights reserved.
The end-user licence agreement shown on first launch is the
Inventivus Single-User License, bundled into the renderer and asserted by
verify:editions.