TreeMap
A free, open-source disk-space visualizer. Squarified treemap, duplicate hunting, disk-full forecasting, and cleanup that only ever moves files to the Trash.
翻译准备中
Track ideas, finance, trading, research, decisions and more — all in one place, with an AI assistant that understands your data and helps you think it through. No more scattered notes, spreadsheets, a
翻译准备中
效率工具JavaScript · TypeScriptGitHub @eliasgiannakos51-hue连接不稳定运营者未确认
试用过了吗? 点一下就好,无需登录。
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证Track ideas, finance, trading, research, decisions and more — all in one place, with an AI assistant that understands your data and helps you think it through. No more scattered notes, spreadsheets, and chat windows.
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
Ionexa AI — "the energy behind everything you build" — is a personal
operating system for running a startup. It combines 13 structured business modules,
an AI-powered free-text inbox that files anything you throw at it into the
right module, and a general-purpose AI assistant, all in one dark,
amber-accented workspace. Next.js 14 (App Router) + Supabase + the Claude
API + Stripe.查看完整 README ↗Ionexa AI — "the energy behind everything you build" — is a personal operating system for running a startup. It combines 13 structured business modules, an AI-powered free-text inbox that files anything you throw at it into the right module, and a general-purpose AI assistant, all in one dark, amber-accented workspace. Next.js 14 (App Router) + Supabase + the Claude API + Stripe.
There's a public marketing site (landing page, pricing, roadmap, terms,
privacy) at the root, then everything else lives behind email/password auth
on /dashboard. Every module gets the same feature set: create, inline
edit, delete (all RLS-scoped to the logged-in user), live search,
newest/oldest sorting with pagination, CSV export, toast notifications on
every mutation, and loading/error states. The whole app is responsive
(sidebar collapses to a hamburger overlay on small screens) and works on
mobile and desktop.
Touch targets: measured, not claimed. This paragraph used to assert
"44px minimum touch targets" as a finished property. It was not one, and
nothing checked it — the claim survived because every layout test in the
repo ran against an empty account, where the only thing on screen is a
centred empty state. scripts/tests/layout-stress.prodtest.mjs measures
it against an account with real data in it, at four widths in two locales,
and prints the count every run. It was 346. It is now 119, most of the
remainder being inline links in prose (which WCAG 2.5.8 exempts) and
controls 4px short. The number is pinned so it cannot grow, and the same
file also measures three things nothing else does: text clipped with no
way to read the rest, controls a tap physically cannot reach, and the
modals.
/dashboard/overview) — the default landing page after
login. Totals across all modules (entry count, entries in the last 7
days, most-active module) plus a card per module showing its entry count
and most recent entry, linking through to the module./dashboard for Ideas, /dashboard/<slug> for
the rest) — Ideas, Competitors, Research, Finance, Learning, Trading,
Decisions, Products, Content, Sales (CRM), Feedback, Analytics, and
Automation. Ideas is hand-built; the other 12 are driven by a shared
config (src/lib/modules.ts) and generic list/form components
(src/components/modules/), so every module gets the exact same
list/form/RLS pattern without 12 near-duplicate files./dashboard/create) — a single free-text box,
reachable from anywhere in the dashboard with Cmd+K / Ctrl+K. It posts to
/api/create, a server-only route that calls the Claude API
(ANTHROPIC_API_KEY, never exposed to the client) with a forced tool call
to classify the message into one of the 13 modules and extract that
table's fields, then inserts it via the same RLS-scoped pattern as every
other module. If nothing matches clearly, it explains the available
modules instead of guessing./dashboard/chat) — a separate, general-purpose AI
assistant (not tied to any module) with real token-by-token streaming,
markdown-rendered replies, and a conversation sidebar (pin, rename,
delete, grouped by recency). Backed by /api/chat./dashboard/agents) — autonomous agents. The user
describes what they want in one sentence ("every morning, send me the
latest news about Nvidia"); /api/agents/build asks only the questions
that are genuinely missing (the shared clarifying-questions pre-check,
kind agent), designs a complete configuration, and shows a preview —
what it understood, when it will run, the first three run times in the
user's own timezone, and the credits each run will cost — before
anything exists. Confirmed agents run on our infrastructure forever:
/api/cron/agent-runs fires every 15 minutes, executes whatever is due
(optionally with a web search first), and emails the result via Resend.
Each execution reserves credits before the first token and settles on
measured usage, retries twice on a transient failure, auto-pauses the
agent if the account runs out of credits, and switches it off with an
email after five consecutive failures. Per-agent controls: pause/resume,
edit, delete, "Run now" (a real execution, which deliberately does not
touch the schedule or the failure streak), and the full run history with
each run's output, cost and outcome. Fair use is per plan (Free 0,
Starter 2, Growth 5, Professional 15, Ultimate 50, Enterprise 100), every
number overridable via AGENT_LIMIT_*, plus a per-account cap of
AGENT_MAX_RUNS_PER_HOUR executions an hour./dashboard/published) — real hosting. A finished
site in the Website Builder gets a Publish button: the user picks an
address (3-30 chars, [a-z0-9-], unique, with a reserved blocklist that
keeps admin, support, billing, www and ~70 others out of
customers' hands) and the site goes live immediately at /s/<address> —
or at <address>.<PUBLISHED_SITE_DOMAIN> once a wildcard domain exists,
with no migration, because the stored value is a bare label. The public
route (src/app/s/[subdomain]/route.ts) reads no session, returns the
published bytes as their own document rather than injecting them into
ours, and carries a CSP that permits the inline CSS/JS a single-file
generated site needs and nothing else — no external script host,
form-action 'self', frame-ancestors 'none', base-uri 'none' —
plus X-Frame-Options, nosniff, a Permissions-Policy and an
in-memory rate limit. Every publish and every rollback re-runs the
static security scan, fail-closed. Re-publishing appends a version;
the last 20 are kept and any of them can be rolled back to in one click.
Analytics are views per day with no cookies and no personal data of
any kind — the table has no column that could hold an IP, a user agent
or a visitor id. Fair use per plan (Free 0, Starter 1, Growth 3,
Professional 10, Ultimate 30, Enterprise unlimited), overridable via
PUBLISHED_SITE_LIMIT_*./dashboard/integrations) — Gmail, Google Drive and
Slack, so the AI works on the user's real data. One generic OAuth flow
serves every provider (/api/integrations/[provider]/connect and
/callback), with CSRF state that is both HMAC-signed and bound to an
HttpOnly cookie, PKCE where the provider supports it, and refresh at the
point of use rather than on a schedule. Tokens are AES-256-GCM
ciphertext with a key that lives only in the environment; each one is
bound by GCM additional-authenticated-data to whose token it is and
which token it is, so a ciphertext copied between rows or columns
fails to decrypt instead of quietly working. Nothing token-shaped can
reach a log — the redactor and a string-only safeErrorDetail are the
only paths into logApiError. Gmail and Drive are deliberately
separate integrations sharing one Google client, so a user who wants
the AI to read their files never has to hand over their mail; Gmail is
read with format=metadata (subjects, senders, a snippet — never
bodies, never attachments, never Spam or Trash). Consent is a step, not
a checkbox: the panel states what the AI will see and lists the scopes
verbatim before the user leaves for Google. Disconnect revokes at the
provider first, then deletes our row, and the delete cascades to the
audit trail of what was read. In Ionexa Chat the model gets a real
search_my_data tool (the app's first client-side tool loop, bounded at
two rounds), whose results are fenced as untrusted third-party content;
Autonomous Agents can deliver to a Slack channel, which must be one the
user's own connected workspace offers. Fair use per plan (Free 0,
Starter 2, Growth 5, Professional+ unlimited) via INTEGRATION_LIMIT_*./dashboard/settings) — account email, password change,
current plan + billing management, Buy Credits, credit transaction
history, and a full-data JSON export./dashboard/team) — Professional/Enterprise plan owners can
invite teammates by email; invited members get full access at the
owner's plan for a flat €20/month/seat./pricing) — six tiers (Free, Starter, Growth, Professional,
Ultimate, Enterprise), each with a monthly credit allotment (EUR pricing)
and a set of plan capabilities, plus a team-seat add-on on Professional+.
Checkout supports Stripe promotion codes./roadmap) — a public, purely informational page laying out
what's live today, what's coming next, and the longer-term product
vision. No interactive elements by design.Install dependencies
npm install
Create a Supabase project at supabase.com (or use an existing one).
Run the schema. In the Supabase SQL editor, run the contents of
supabase_schema.sql. It creates every table
(the 13 modules, create_requests, chat_conversations/
chat_messages, team_members) and enables row-level security so each
is scoped to user_id = auth.uid().
Configure environment variables. Copy the example file and fill in your project's values (Project Settings → API):
cp .env.local.example .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
ANTHROPIC_API_KEY=your-anthropic-api-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
RESEND_API_KEY=your-resend-api-key
RESEND_FROM_EMAIL="Ionexa AI <[email protected]>"
NEXT_PUBLIC_SITE_URL=http://localhost:3000
STRIPE_SECRET_KEY=your-stripe-secret-key
STRIPE_WEBHOOK_SECRET=your-stripe-webhook-signing-secret
STRIPE_PRICE_STARTER=price_...
STRIPE_PRICE_GROWTH=price_...
STRIPE_PRICE_PROFESSIONAL=price_...
STRIPE_PRICE_ULTIMATE=price_...
STRIPE_PRICE_TEAM_SEAT=price_...
STRIPE_PRICE_CREDITS_10=price_...
STRIPE_PRICE_CREDITS_25=price_...
STRIPE_PRICE_CREDITS_50=price_...
STRIPE_PRICE_CREDITS_100=price_...
STRIPE_PRICE_ADDON_CREDITS_1000=price_...
STRIPE_PRICE_ADDON_AGENTS_5=price_...
STRIPE_PRICE_ADDON_STORAGE_10GB=price_...
STRIPE_PRICE_ADDON_PRIORITY=price_...
CRON_SECRET=your-cron-secret
[email protected],[email protected]
BETA_INVITE_CODE=your-beta-invite-code
BETA_FEEDBACK_URL=mailto:[email protected]
UNSPLASH_ACCESS_KEY=your-unsplash-access-key
OPENAI_API_KEY=your-openai-api-key
ELEVENLABS_API_KEY=your-elevenlabs-api-key
GOOGLE_API_KEY=your-google-ai-api-key
GROQ_API_KEY=your-groq-api-key
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
IONEXA_DIAG — optional. Set to 1 to enable verbose request tracing for the auth middleware, Stripe checkout/webhook, the team-page gate, and the Mission Control/Timeline data loads (see src/lib/diag.ts). Off by default: middleware runs on every request, so leaving this on writes a log line per page view. Turn it on, redeploy, reproduce the issue, read the logs, turn it off again.CREDIT_MARGIN_MULTIPLIER — optional, default 4, allowed range 4-10. Multiplier applied to an action's real API cost before converting to credits. Anything outside the range (or unparseable) is ignored with a logged warning and the default is used.CREDIT_PRICE_EUR — optional, default 0.02. List price of one credit, in EUR.USD_TO_EUR_RATE — optional, default 0.92. Anthropic bills in USD; credits are priced in EUR.LARGE_ACTION_CONFIRM_THRESHOLD — optional, default 50. Estimates above this many credits require explicit user confirmation before the action starts.RESERVE_BUFFER_PERCENT — optional, default 10. Extra percentage held (not charged) when reserving credits, released at settlement.Seven things worth interrupting somebody for — an agent that FOUND
something (never one that merely ran), a website published, research
ready, credits at 80% and 100%, a failed payment, a new team member, and
an error that needs the user. Each one goes wherever that user said, per
type: in-app, email, Telegram, Discord. See src/lib/notify/.
The rules are code rather than intentions: worth-sending.ts refuses a
notification with nothing in it, grouping.ts collapses five agent
results into one, quiet-hours.ts DEFERS rather than drops (the held
notifications are drained by /api/cron/agent-runs, so "held until 08:00"
means 08:00), and engagement.ts reports the click rate per type so
"under 10% and the type is not worth sending" is a query rather than an
opinion.
TELEGRAM_BOT_TOKEN — optional. The bot that delivers Telegram
notifications. Without it the Telegram channel is disabled cleanly:
telegramConfigured() is false, Settings says Telegram is not set up on
this deployment instead of offering a field that cannot work, and the
dispatcher drops the channel however a preference reads. Every other
channel is unaffected. Get one from @BotFather; the user then messages
the bot and pastes back the chat id, and a test message must actually
arrive before the channel is stored as verified.INTEGRATION_ENCRYPTION_KEY, and re-validated at send time.INTEGRATION_ENCRYPTION_KEY (already required by
the OAuth integrations). Without it, connecting a chat channel is
REFUSED rather than stored in plaintext.Email notifications share the existing Resend setup and the same 20-per-day-per-user cap as every other email. They will not leave the building without a verified sending domain — see "Email delivery" below.
Two pages that were CRUD trackers — a form for typing your own findings,
and a form for describing code you would then go and write yourself — and
are now tools. Both moved from the sidebar's Tracking group to
Build, and scripts/tests/sidebar-naming.test.mjs now proves that
claim from the code rather than from a list: every item under Build must
have an API route it fetches that actually reaches a model, and every
module still in lib/build-modules.ts must NOT.
Data Analysis (/dashboard/data-analysis). Upload a CSV or an Excel
file up to 8 MB; it is parsed, every column profiled, and charts drawn
from the real rows. The division of labour is the point:
lib/data-analysis/profile.ts
does the types, means, medians, standard deviations, outliers and
Pearson correlations over the whole file. A mean produced by a language
model is indistinguishable from a real one until somebody's decision
depends on it.lib/data-analysis/. A spreadsheet parser
is a file-format parser running on files strangers upload, which is the
highest-risk dependency class there is; this one does read only, two
compression methods, and refuses everything else by name.AI Coding (/dashboard/coding). Five operations: write a snippet,
explain this, find bugs, convert, write tests. And four things it does
NOT do, stated on the screen in all ten languages rather than in a
comment:
Those four are V5. Syntax highlighting is a tokeniser in
lib/coding/highlight.ts that returns TOKENS rather than an HTML string,
so the component maps them to <span> elements and pasted code goes
through React's normal escaping — there is no dangerouslySetInnerHTML
anywhere near a paste box.
The advantage (lib/ai/workspace-context.ts). Both tools can read a
short, bounded list of headlines from the user's OWN modules, so "a
function that calculates the margin" can mean what margin means in this
account. Four rules: the caller's own RLS-scoped client (never the admin
one), headline fields only, capped per module and in total, and only when
the caller explicitly asks — the panel has a visible toggle.
Nothing was deleted. ai_coding_requests and
ai_data_analysis_requests are untouched and still in the GDPR export.
The coding notes were copied into code_sessions (marked
source = 'note', idempotently, keyed on imported_from), and the
analysis notes are listed on the new page under "your earlier notes".
Chat can see the AI Coding module's history, and Coding can see the
relevant chat turns (src/lib/ai/cross-module-context.ts). That is what
makes "remember the function you wrote?" and "why did you do it that
way?" answerable — the second one especially, because the reason lives
in what the model said, not in the code it produced.
Only the relevant, never everything. Selection uses the same matching
rule as module selection — questionWords and scoreTerms, imported
from context-relevance.ts rather than reimplemented — but a different
policy. Module selection has a floor (at least six modules, because a
question about sales is still asked by somebody whose business is all of
them). Item selection has no floor and starts at zero: nothing
matching means nothing is added, and the request goes out byte-identical
to what it was before this existed.
Measured by node scripts/measure-context.mjs, against the 20,725
characters a chat request already sends:
| Question | Items kept | Added | Share |
|---|---|---|---|
| about code, specific | 2 | 707 chars | 3.4% |
| about code, vague | 0 | 0 | 0% |
| not about code | 0 | 0 | 0% |
| under 20 characters | 0 | 0 | 0% |
Worst case 707 characters (~177 tokens), 3.4% — an order of magnitude inside the "must not double the context" rule. Budget is 900 characters for the whole block, header included; a first version budgeted only the items and rendered 1,151, which the measurement caught.
Two placement rules, both load-bearing:
scripts/evals/ is the measured answer to "is it getting better?".
154 cases across seven capabilities — chat, create, website, agents,
research, files, mission — 22 each, every one carrying the specific
failure it exists to catch. Almost every check is mechanical (a
regex, a structural property, a numeric bound), so the number is
reproducible and can be argued with by reading it.
ANTHROPIC_API_KEY=sk-ant-... node scripts/evals/run.mjs
ANTHROPIC_API_KEY=sk-ant-... node scripts/evals/run.mjs --capability chat --model claude-haiku-4-5-20251001
ANTHROPIC_API_KEY=sk-ant-... node scripts/evals/run.mjs --out baseline.json
ANTHROPIC_API_KEY=sk-ant-... node scripts/evals/run.mjs --compare baseline.json
It prints Capability | Success rate | Avg score | Median & p90 latency |
Cost. --compare is the rollback decision: it re-runs, diffs against a
stored baseline, and exits non-zero if any capability lost more than
10% of its success rate.
Four properties make the number trustworthy, and each is enforced by a test:
null, never 0%. Printing 0% would read
as total failure when the truth is "nothing ran".usage. An unpriced model
reports unknown, not a confident zero.Like the website variety check, this makes real, billed calls and is
deliberately not in scripts/tests/. The scorer is fully tested
without a key (scripts/tests/evals.test.mjs, 92 assertions), including
that every one of the 154 cases parses, has a stated reason, compiles its
regexes, and cannot be satisfied by an empty answer — an
injection-refusal case that a blank response would pass is the most
dangerous kind of false green.
src/lib/ai/routing/ decides which model serves which request. Every
request is classified trivial / simple / complex / expert by
deterministic rules — the feature, the prefix size, the input length,
whether the output is structured, whether anyone will read it — and only
an unrecognised feature falls through to a classifier call. The whole
decision is pure and costs 0.002ms, against the 50ms budget.
When in doubt it goes UP. An under-routed request produces a bad answer the user paid for; an over-routed one costs a fraction of a cent. Unattended work (a scheduled agent, an automation step) never runs on the cheapest rung, because nobody is going to read the bad answer and try again.
The cache-minimum trap, which is the whole reason this is not a lookup table. Haiku's prompt cache needs 4,096 tokens of prefix; Sonnet's needs 1,024. A request with a 2,000-token system prefix caches on Sonnet and does not cache on Haiku:
| Prefix | Haiku (min 4096) | Sonnet (min 1024) | |
|---|---|---|---|
| 500 | $0.000500 | $0.001500 | Haiku cheaper |
| 1,024 | $0.001024 | $0.000307 | Haiku costs 3.3× more |
| 2,000 | $0.002000 | $0.000600 | Haiku costs 3.3× more |
| 4,095 | $0.004095 | $0.001229 | Haiku costs 3.3× more |
| 4,096 | $0.000410 | $0.001229 | Haiku cheaper |
Between those two numbers the "downgrade" costs 3.3× more for the
prefix on every single call, while the per-token rate went down and
every report says the change worked. The router prices both models on the
actual prefix and refuses any downgrade that is not really cheaper. The
arithmetic is asserted in scripts/tests/model-routing.test.mjs.
Escalation is charged once. If the cheap model fails in a way a stronger one could fix — malformed output, a declined capability, a truncation, a failed verification — the router climbs one rung. A safety refusal, a rate limit or an auth error does not escalate: retrying a refusal on a stronger model is shopping for a different answer to a question that was already answered. The customer is charged for the attempt that succeeded; the failed cheap attempt is absorbed by us, because they never chose the cheap model — our routing did.
It learns, without oscillating. routing_decisions records every
decision and its outcome, and routing_success_rates() aggregates the
last 30 days. A model below 90% on a feature stops being chosen for
it — but only after at least 20 runs, because two failures out of two
is not evidence.
/dashboard/routing (owner-only, 404 not 403) shows which model went
where, what customers were charged, what our own failed attempts cost
us, and what the cache rule saved. That third column is the one that
matters: a router that saves money by charging users for its own failed
guesses is not saving money, it is moving the cost.
Changes here are validated by the eval baseline above —
node scripts/evals/run.mjs --compare baseline.json exits non-zero if
any capability lost more than 10% of its success rate.
Nothing leaves the building without this. The default sender,
[email protected], is Resend's shared TESTING address: it delivers
ONLY to the email address of the Resend account owner. Every other
message — welcome, digest, agent result, notification — is refused with
"You can only send testing emails to your own address". Production
logged twenty of those before RESEND_FROM_EMAIL was upgraded from
optional to recommended in src/lib/env-check.ts.
It is not a code problem and there is no code fix for it. It is a DNS problem, and this is exactly what to do:
Pick a subdomain to send from, not the root domain — send.example.com
or mail.example.com. Sending reputation attaches to whatever you sign
with, and keeping it off the root means a bad week for marketing mail
never affects the domain your normal business email arrives from.
Add the domain in Resend: https://resend.com/domains → Add Domain → enter the subdomain → pick the region closest to your users (it changes one of the records below).
Resend then shows you three or four records. Copy them EXACTLY as shown — the values are generated for your domain and are not guessable, so do not copy them from any documentation including this file. What you will see:
TXT, name resend._domainkey, value a
long p=MIG... public key. This is what signs the mail as yours.TXT, on the sending subdomain, value
v=spf1 include:amazonses.com ~all. This is what says Resend's
servers may send as you.feedback-smtp.<your-region>.amazonses.com, priority 10. This is
where bounces and complaints go; without it you never learn that an
address is dead.TXT, name _dmarc, value
v=DMARC1; p=none; to start. Add it AFTER the first three verify,
and leave it at p=none until you have looked at a few reports.Add each record at your DNS provider (Cloudflare, Namecheap, your
registrar). Two things that bite here: some providers append the
domain to the name automatically, so entering
resend._domainkey.example.com produces
resend._domainkey.example.com.example.com — enter just
resend._domainkey. And on Cloudflare, DNS records for mail must be
DNS only (grey cloud), never proxied.
Click Verify in Resend. It is usually minutes; DNS propagation can take up to 72 hours. The dashboard shows per-record status, so a single failing row tells you which one was typed wrong.
Set the sender once the domain shows Verified:
RESEND_FROM_EMAIL="Ionexa AI <[email protected]>"
Until step 6 is done, the notification system's email channel is a correctly-built path with nothing at the end of it. In-app notifications, Telegram and Discord are unaffected — they do not go through Resend.
A monthly allowance of chat messages that cost no credits. Free messages run in a smaller envelope than paid ones — 2,000-character input, 6 messages of history, 800 output tokens, and no web search — which is what keeps the worst case affordable enough to give away (≈€0.035 per message, versus ≈€1.01 for a full paid message with three web searches). See src/lib/billing/free-chat.ts; npm run test:unit enforces that no allowance can exceed 25% of its plan price.
Requires supabase_free_chat_migration.sql to have been applied. Without it the feature degrades to off and chat charges credits exactly as before.
FREE_CHAT_ENABLED — optional. Set to false to disable free chat entirely on every plan.
FREE_CHAT_MESSAGES_FREE — optional, default 15.
FREE_CHAT_MESSAGES_STARTER — optional, default 120.
FREE_CHAT_MESSAGES_GROWTH — optional, default 300.
FREE_CHAT_MESSAGES_PROFESSIONAL — optional, default 600.
FREE_CHAT_MESSAGES_ULTIMATE — optional, default 1200.
FREE_CHAT_MESSAGES_ENTERPRISE — optional, default 1200.
Each accepts a non-negative integer; 0 disables the allowance for that plan. An unparseable or negative value is ignored and the default is used. The allowance resets on the first of each calendar month (UTC).
Your own trades, your own rules, counted (V4 #14). Lives at
/dashboard/trading-journal; the schema extends the existing trades
module table rather than forking it, so one question has one answer.
The Guardian parses once and counts in code. You write your rules the
way you would say them — "Max 2% risk. Only London. RR at least 1:2. Max
3 trades a day." — and they are turned into a checkable form in the
browser, with no model call (src/lib/trading/rules.ts is pure). What
was understood is shown beside your own sentence before anything is
saved, and a sentence that cannot be made checkable is refused rather
than stored: a rule sitting in the list marked active that can never
fire is worse than no rule, because you would believe you were being
watched.
Every trade is then evaluated by arithmetic (src/lib/trading/guardian.ts),
so "you broke this rule eight times in March" is a number you can check
one trade at a time — not a model's impression of 200 rows, which would be
different tomorrow.
Three things it gets right that are easy to get wrong:
null
and rendered as a sentence explaining why, never as a number.Read-only, always (V4 #15). Requires
supabase/migrations/20260831000000_bank_crypto.sql.
Six rules, each enforced by something that fails when it stops being true — not by a promise in a comment:
bank_connections.access_mode is a CHECK
constraint pinned to read_only, the scopes array is constrained to
four read scopes, and src/lib/finance/read-only.ts is the only way
this codebase calls a financial provider — it refuses any method but
GET/POST and any path containing transfer, payment, payout,
withdraw, beneficiar, sign and a dozen more.crypto_wallets has one
address column and no jsonb to hide one in;
src/lib/finance/secret-guard.ts recognises BIP-39 mnemonics, raw hex
keys, WIF and xprv by shape and refuses them without echoing
the value into an error, a log or the DOM. The database refuses them
again: the address column forbids whitespace and is bounded at 128
characters. A watch-only xpub is deliberately not refused.src/lib/trading/conduct.ts constrains
every trading-related model call in both languages and scans the
output before it reaches a user. A response that recommends is
replaced, not edited — editing an advisory sentence leaves the advice
and removes the evidence.TradingDisclaimer is a
server component with no dismiss control, and the build gate fails
if any dashboard surface reading trading, bank or crypto data omits it.
Its text is checked in all ten locales for all three claims: not
advice, not a forecast, risk of loss.src/lib/integrations/crypto.ts) —
not a second implementation, because one encryption path means one
place for a key to be mishandled. The build gate asserts that nothing
in src/lib/trading/ or src/lib/finance/ writes to the console at
all.No provider keys are wired yet. No bank was ever connected, no wallet balance was ever read, and no aggregator credential exists in this codebase. The schema, the guards and the read-only layer are in place and tested; the sync itself is the next piece.
One interface, several providers (V4 #12). Every model call can go through
src/lib/ai/providers/, which picks a provider per purpose, translates
the request, and fails over when one is having an incident. The user
never sees any of this — the same answer, the same error sentence,
whoever served it.
Off unless you turn it on. With no configuration the chain is
Anthropic alone and behaviour is exactly what it was. Adding a provider is
an explicit act: a key added for something else (OPENAI_API_KEY is also
what Voice transcription uses) does not silently reroute your chat.
OPENAI_API_KEY — optional. Also used by Voice. Without it OpenAI is
dropped from any chain that names it, with the reason logged.GOOGLE_API_KEY — optional. Gemini.GROQ_API_KEY — optional. Open models, fast and cheap, no prompt
cache — see the warning below.AI_PROVIDER_ORDER — optional, default anthropic. Comma-separated,
tried in order, e.g. anthropic,openai,groq. An unknown name is
warned about and skipped; the rest of the chain still stands, because
a typo here must not take every AI feature down at once.AI_PROVIDER_ORDER_<PURPOSE> — optional per-purpose override, beating
the global chain. Purposes: CHAT, AGENT_RUN, AGENT_BUILD,
RESEARCH, WEBSITE_BUILD, CREATE, FILE_ASK, CLASSIFICATION,
SUMMARISATION. Example: AI_PROVIDER_ORDER_CLASSIFICATION=groq,anthropic.AI_FAILOVER_ENABLED — optional, default true. Set to false for
deterministic single-provider behaviour.What fails over and what does not. 5xx, 529, 429, timeouts and network failures move to the next provider. A 400 does not — it is our bug, and paying a second vendor to reject the same malformed request hides it. A 404 does not — it means the model catalog is wrong, permanently. An error nobody can classify does not either.
⚠️ The cache minimum, and why a cheaper provider can cost more
Prompt caching fails silently. Below a model's minimum prefix length the provider does not cache, reports nothing unusual, and bills full price.
Model Minimum cacheable prefix claude-sonnet-4-61,024 tokens claude-haiku-4-54,096 tokens openai/gpt-*1,024 tokens (automatic, no markers) google/gemini-2.5-flash1,024 tokens google/gemini-2.5-pro2,048 tokens groq/*no prompt cache at all The minimum is not monotonic with price. Routing a call with a 2,000-token cached prefix from Sonnet "down" to Haiku to save money:
on Sonnet 2,000 cached read tokens @ $0.30/MTok = $0.00060 on Haiku 2,000 FULL input tokens @ $1.00/MTok = $0.00200The cheaper model is 3.3x more expensive for that prefix, on every request, and nothing reports it.
src/lib/ai/providers/cache-policy.tscomputes this before a route is taken,comparedRequestCostUsd()answers "is it actually cheaper for this request", and every attempt inai_provider_logcarries acache_keptcolumn — the only trace a silent failover leaves.
Prices are not verified. The non-Anthropic rows in
src/lib/ai/providers/catalog.ts are published list prices written down
without an account to check them against. Every credit charge is computed
from them. Check them before serving real traffic — the app logs a
warning naming that file the first time an unverified provider is enabled.
Who served what is in public.ai_provider_log: one row per attempt,
with the provider, model, outcome, HTTP status, latency, why that provider
was tried, and whether the cache survived. No prompt, no completion — the
table has no column that could hold either. Requires
supabase/migrations/20260828000000_ai_provider_log.sql; without it
routing still works and the log write fails quietly (it never blocks a
call).
Scheduled agent runs can go through Anthropic's Message Batches API at
half price (V4 #13), collected by /api/cron/agent-batches.
AI_BATCH_ENABLED — optional, default false. Must be exactly the
string true. Off by default because it changes when results arrive,
and deciding on somebody's behalf that a delay does not matter to them is
not an optimisation, it is a product change.What qualifies, and nothing else does: a scheduled run (not manual — somebody is watching a spinner), on an agent that runs daily or slower (an hourly agent could have 24 submissions outstanding), that does not need live web research (a batch cannot use the server-side search tool, and answering from training data instead produces a confident, unsourced report that looks exactly like a real one), with no batch already in flight — enforced by a partial unique index in SQL, not by application code, because it is a race between cron invocations.
The delay is named, not hidden. A batched run is written as queued
with its submission time and shown as "Queued" in the agent's history,
never as "Running". Individual agents can opt out with batchOptOut in
their config.
When it fails. The batch expires (24h), a request inside it errors, or it is cancelled → that run is closed with a sentence the owner can read and the agent is made due immediately, so the ordinary synchronous path runs it at full price. Submission itself failing queues nothing and the agent runs synchronously in the same tick. There is no second executor and no retry loop of its own.
Credits are not held across the window. A reservation lives 60 minutes and a batch may take 24 hours. Affordability is checked at submission and the charge is taken at settlement from measured usage, at the batch rate. The gap is real: a user who spends their balance in between is charged against what is left. The exposure is one run of one agent at half price.
Requires supabase/migrations/20260829000000_agent_run_batches.sql.
Speaking to the app instead of typing, and having answers read back (V4 #19, #23, #2). Two provider keys, both optional to the deployment and mandatory to the feature:
OPENAI_API_KEY — optional, no default. Transcription (whisper-1).
Without it, no microphone button renders anywhere. Not a broken
button and not an error toast: /api/voice/usage reports
configured.transcribe: false, and every VoiceInput returns null.
Typing is unaffected everywhere, because the microphone was never the
only way in.ELEVENLABS_API_KEY — optional, no default. Speech (eleven_turbo_v2_5).
Without it, no "Listen" button renders and the hands-free
conversation entry point in Chat is hidden, since a loop that can hear
but not answer aloud is not the thing that button promises. Reports
configured.speak: false.Both keys are checked by name, before any client is constructed — the
new Resend(undefined) lesson: a provider SDK that throws from its own
constructor makes "the key is missing" indistinguishable from "the
network is down".
The audio is stored nowhere. It is streamed to the provider for transcription and the transcript comes back to the browser for the user to edit; nothing is written to Supabase, to storage, or to a log. The same is true in reverse for speech: the clip is a response body and a blob URL that is revoked when the component unmounts.
Minutes per plan, a capacity ceiling on top of the credit charge (the
providers bill per minute and per character, so a tab holding a
microphone open is somebody else's invoice). Each accepts a non-negative
integer; 0 disables voice for that plan entirely, and the UI then says
"not included on your plan" rather than hiding the reason. Requires
supabase/migrations/20260827000000_voice_usage.sql; without it the
voice_usage ledger cannot be read and the routes fail closed — the
month reports as fully used and voice refuses, which is the safe
direction for a metered external cost.
VOICE_MINUTES_FREE — optional, default 0 (voice is not on the free plan).VOICE_MINUTES_STARTER — optional, default 30.VOICE_MINUTES_GROWTH — optional, default 90.VOICE_MINUTES_PROFESSIONAL — optional, default 300.VOICE_MINUTES_ULTIMATE — optional, default 900.VOICE_MINUTES_ENTERPRISE — optional, default 2000.What it costs, in credits, at the default margin. Listening is cheap and speaking is not, by roughly sixteen times, so the price is on the button before it is pressed:
| Action | Provider price | Credits per minute (M=4) | (M=5) | (M=6) |
|---|---|---|---|---|
| Transcribe | $0.006/min | 2 | 2 | 2 |
| Speak | $0.15/1k chars | 25 | 32 | 38 |
A 30-second dictation is 1 credit. A 1,200-character answer read aloud is 42 credits at M=5 — more than a standard agent run, which is exactly why that number is rendered on the "Listen" control rather than discovered afterwards.
`.env.local` is gitignored — never commit real credentials.
`ANTHROPIC_API_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `RESEND_API_KEY`, the
`STRIPE_*` vars, `ADMIN_EMAILS`, and `BETA_INVITE_CODE` have no
`NEXT_PUBLIC_` prefix on purpose — they're only read server-side and are
never sent to the browser. `RESEND_FROM_EMAIL` is optional — it falls
back to Resend's shared sandbox address, which only delivers to the
email on your own Resend account, so set it to a verified sending
address before real users sign up. `ADMIN_EMAILS` is the only source of
the admin list in `src/lib/auth/admin-emails.ts` (no address is written
in the code since 2026-10-04): unset, nobody is an admin and no owner
alert is sent — see [Admin access](#admin-access) below.
`BETA_INVITE_CODE`/`BETA_FEEDBACK_URL` are optional — see
[Beta testers](#beta-testers) below. `UNSPLASH_ACCESS_KEY` is optional —
see [Website Builder photos](#website-builder-photos) below; without it,
image resolution falls back to a solid-color placeholder instead of a
real photo. `OPENAI_API_KEY` and `ELEVENLABS_API_KEY` are optional —
see [Voice](#voice) above; without them the microphone and "Listen"
controls do not render at all and every surface stays fully usable by
typing and reading. `GOOGLE_API_KEY` and `GROQ_API_KEY` are optional
too — see
[Model providers and failover](#model-providers-and-failover); a
provider with no key is dropped from the routing chain cleanly, with
the reason logged, and never causes a failed call. See [Billing](#billing) and
[Credits](#credits) below for how the Stripe vars are used and how to
create the required Price IDs.
5. **Email confirmation is auto-skipped for now.** Signup goes through
`/api/signup`, which creates the user, immediately marks their email
confirmed via the Supabase Admin API (`SUPABASE_SERVICE_ROLE_KEY`), and
signs them in — so new users land straight on `/dashboard/overview` with
no inbox step. This is intentional for early development; re-enable real
email confirmation (drop the `email_confirm: true` admin call in
`/api/signup/route.ts`) before shipping to real users.
6. **Run the dev server**
```bash
npm run dev
Open http://localhost:3000 — you'll be
redirected to /login. Sign up, then you'll land on /dashboard/overview
with a sidebar to reach every module.
tests/smoke.spec.ts is a Playwright suite that
objectively checks every module has create/search/sort/export, that
sidebar links resolve, and that login/signup, Overview, Settings, the
Cmd+K shortcut, and the mobile sidebar all work — rather than relying on
reading the code. Run it against your local dev server:
npx playwright install chromium # first time only
npm run test:e2e
It starts npm run dev for you if it isn't already running. The three
"public pages" checks need nothing extra, but everything under
"authenticated dashboard" signs up a throwaway test account through the
real signup flow, so it needs a working .env.local with real Supabase
credentials (see Setup above) — if signup can't reach Supabase, those
tests report as skipped with the reason, not failed.
scripts/website-variety-check.mjs answers it with numbers instead of
opinions. It is deliberately not in scripts/tests/: it makes four
real, billed Anthropic calls (~$1.50), so it must never run in the build
gate.
ANTHROPIC_API_KEY=sk-ant-... node scripts/website-variety-check.mjs --out ./variety-out
It generates a cafe, a law firm and a photographer, writes one .html
per brief (open them side by side — that is the screenshot), and prints
two independent verdicts:
The two are kept apart on purpose. Five earlier rounds shipped a good structural number while the complaint stood, because nothing was computing the second one: two pages can have different section orders and still be the same product to a visitor if they are the same colour, in the same fonts, at the same density, moving at the same speed.
Ten design axes are drawn by code per site
(src/lib/website-variation.ts) and delivered in the uncached user
message, so the cached system prompt stays byte-identical and the prompt
cache keeps hitting. Six are structural/compositional (section order,
hero, grid, rhythm, type scale, motion vocabulary); four are visual
(palette strategy, type pairing, spacing scale, motion timing). Anything
left as prose in the system prompt is the same instruction on every call
and gets the same answer — which is the whole reason the draw exists.
The instrument itself is unit-tested without an API key
(scripts/tests/site-fingerprint.test.mjs), in both directions: two
pages that should score alike and two that should not.
The harder question, and the one the templates work is judged by.
scripts/website-pairs-check.mjs generates ten pairs — two different
businesses of one kind each (two cafés, two law firms, …) — through the
production generator and the production draw, seeded as two different
users' first sites, which is the pair the product cannot tell apart by
memory. Twenty billed generations (~$7.50), so again not in the
build gate; --pairs N runs the first N.
ANTHROPIC_API_KEY=sk-ant-... node scripts/website-pairs-check.mjs --out ./pairs-out
node scripts/website-pairs-check.mjs --dry ./pairs-out # re-score saved pages, no key, no calls
Per pair it prints the declared decisions (archetype / hero / section
order) and whether the two collide, the structural score twice (the
landmark sequence, and src/lib/website-structural-similarity.ts — the
code the process route would use to enforce it), and the four visual
axes; then the same thresholds as above (0.85 same skeleton, 0.7 same
look) and exit 1 when any pair crosses one, so after the fix it is a
gate. --same-user seeds one user's first and second site instead —
what an exclusion of already-used orders would change. The scoring and
the report are tested with pages on disk
(scripts/tests/website-pairs-check.test.mjs) and that gate has its own
mutation suite.
Transactional email is sent via Resend.
Welcome email — sent from /api/signup right after a new user's
email is auto-confirmed, introducing the 13 modules. It's fire-and-forget
by design: sendWelcomeEmail catches and logs its own errors, so a
misconfigured RESEND_API_KEY or a Resend outage never blocks signup —
the user can always log in even if the email didn't go out.
Team invite email — sent from /api/team/invite when a plan owner
invites a teammate.
Weekly digest — /api/weekly-digest, scheduled in vercel.json
at 0 8 * * 1 (08:00 UTC every Monday). It reads what actually
happened in the last seven days (lib/notify/digest-data.ts) and
composes what is worth saying about it (lib/notify/digest.ts), and it
is silent on a quiet week: a digest with no lines is never sent. Each
user's weekly_digest preference and the daily cap are checked by
lib/email/email-gate.ts before anything goes out.
This paragraph, and the monthly credit reset below it, both described
routes as unscheduled for longer than that was true, and on 2026-09-04
one of those sentences was read back as a finding: a live feature
reported as dead because the documentation, not the product, was out of
date. scripts/tests/cron-wiring.test.mjs now fails the build when a
scheduled route is described that way here as well as in its own source.
The route still refuses to run without CRON_SECRET — an
unauthenticated hit would email every user.
When a generated website needs a real photo (a room, food, a product, a
team) and no reference image was uploaded for it, Website Builder emits a
placeholder marker that a post-processing step (src/lib/website-image-resolver.ts)
resolves to an actual, working photo URL — never a broken or invented link.
UNSPLASH_ACCESS_KEY set — resolves to a real Unsplash photo
matched to what the site actually needs (e.g. "modern hotel room
interior"), via Unsplash's Search API. See .env.local.example for
step-by-step instructions on getting a free key from
unsplash.com/developers.Every photo that does appear is a real, legal, functioning image — this app never scrapes Google Images and never uses a photo it has no licence for.
Unsplash's three API guidelines are requirements, not suggestions, and they are what a production application (50 -> 5,000 requests/hour) is granted on. All three are enforced in code:
images.unsplash.com and are never
copied into our storage. This is an obligation, so do not "optimise" it
by caching the bytes or routing them through next/image.links.download_location
(registerUnsplashUses, src/lib/website-image-resolver.ts). It fires
after the document is saved, so a rejected edit or a flagged generation
never counts as a use.utm_source=ionexa&utm_medium=referral on both links, rebuilt
from the photographer carried on the <img> itself every time a
document is stored, so an AI edit cannot quietly drop it.node scripts/unsplash-attribution-proof.mjs renders all three in a real
browser under the published-site CSP; with UNSPLASH_ACCESS_KEY set it
uses the live API and its screenshots are what the application is made
with.
Subscriptions run through Stripe Checkout + the Billing Portal — no card data ever touches this app's servers.
src/lib/billing/plans.ts) — Free, Starter (€20), Growth
(€50), Professional (€100), Ultimate (€200), Enterprise (custom, Contact
Sales only), shown on /pricing. All pricing is EUR
(CURRENCY_SYMBOL) — every Stripe Product backing this app was created
in EUR. Each plan carries a capabilities object (max AI agents,
Website/Automation Builder, Mobile/SaaS Builder, image/video generation,
AI Memory, team collaboration) enforced server-side wherever the
corresponding page or action exists, and a monthlyCredits allotment —
see Credits below. Professional, Ultimate, and Enterprise also offer
a €20/month team seat add-on (TEAM_SEAT_PRICE) with an adjustable
quantity (starts at 0 in Checkout, changed later from the Billing
Portal). Checkout has allow_promotion_codes: true, so Stripe's hosted
page shows an "Add promotion code" field — coupons are created and
managed entirely in the Stripe Dashboard, no app code involved.STRIPE_PRICE_STARTER
/ STRIPE_PRICE_GROWTH / STRIPE_PRICE_PROFESSIONAL /
STRIPE_PRICE_ULTIMATE / STRIPE_PRICE_TEAM_SEAT /
STRIPE_PRICE_CREDITS_10 / STRIPE_PRICE_CREDITS_25 /
STRIPE_PRICE_CREDITS_50 / STRIPE_PRICE_CREDITS_100. Then add a
webhook endpoint pointed at <your-domain>/api/webhooks/stripe
subscribed to checkout.session.completed,
customer.subscription.updated, customer.subscription.deleted, and
invoice.paid, and set its signing secret as STRIPE_WEBHOOK_SECRET./pricing's paid-plan buttons POST to /api/checkout, which
creates (or reuses) a Stripe Customer for the logged-in user and a
subscription-mode Checkout Session, then the browser is redirected to
the session's own url. /api/webhooks/stripe re-derives the user's
subscription_tier and seat_count from the live subscription on every
relevant event and writes them to that user's auth.users.raw_user_meta_data
via the service-role client — nothing else in the app reads Stripe
directly.Four optional purchases sit alongside the plan
(src/lib/billing/addons.ts): +750 credits (EUR15, one-off; 1,000 until 2026-10-03),
+5 agents (EUR10/month), +10 GB storage (EUR5/month) and
priority execution (EUR20/month, not stackable). Each needs its own
Stripe price ID:
| Env var | Add-on | Default | Without it |
|---|---|---|---|
STRIPE_PRICE_ADDON_CREDITS_1000 | +750 credits | unset | The add-on is listed as unavailable and the buy button is disabled; the panel names the missing variable. Nothing else is affected. |
STRIPE_PRICE_ADDON_AGENTS_5 | +5 agents | unset | Same — and every agent cap stays at the plan's own number. |
STRIPE_PRICE_ADDON_STORAGE_10GB | +10 GB storage | unset | Same. |
STRIPE_PRICE_ADDON_PRIORITY | Priority execution | unset | Same. |
All four are optional. An unset variable never 500s a checkout: the
route refuses with not_configured and names the variable, because a buy
button that reaches Stripe with an undefined price reads to a customer as
"this product is broken" rather than "this is not set up".
Agent caps are read through maxAgentsForAccount(), which is the plan cap
plus whatever agents_5 add-ons the account holds — no creator route
reads maxAgentsForPlan() directly any more.
When an action needs more credits than the balance holds,
reserveCredits() — the one function every paid action already goes
through — asks decideOverage() whether the shortfall may be bought at
EUR0.03/credit. It says no unless all of the following are true, and
the default with no settings row is no:
OVERAGE_CONSENT_VERSION (a change to the terms
invalidates old consent and the user is asked again),The price is snapshotted at consent, so a list-price rise never applies to standing consent. Warnings go out at 80% and 100% of the cap through the one notification path, once each per calendar month. Each month's overage becomes one separate line on the next Stripe invoice ("Usage overage — N extra credits"), created by the daily cron once the month has closed and keyed for idempotency on customer plus month. Turning it off is one click and deletes the settings row; the ledger is never touched, because deleting it would be deleting an invoice.
No new environment variable is required — EUR0.03/credit, the EUR1
minimum cap and the EUR10,000 maximum are constants in
src/lib/billing/overage.ts.
The "Made with Ionexa" badge comes off two ways: upgrade to Starter, or
pay 200 credits per site per calendar month
(src/lib/publishing/badge-credits.ts). The price is derived, not
chosen:
Margin ≥4x holds by construction: badge removal makes no model call, so it consumes none of the 25% AI-spend ceiling. Credits spent here are credits not spent on inference, and on the free plan they must have been purchased — revenue at €0.02/credit against €0.00 of cost. The gate asserts this as a property (nothing in the badge-credit path may import a completion) rather than as a number somebody typed.
What the badge itself earns in referrals is not modelled: nothing in this codebase counts badge click-through, so the opportunity cost of removal is unknown and any figure for it would be invented.
The rules, each enforced by a test:
(site_id, covers_month),
unique at the database level.credits_low notification — and not warned when the credits are
there, because a warning that is not true is how a channel gets muted.public.site_shows_badge(site_id)
answers plan and purchase in one round trip on the hottest path in
the product, and fails towards the badge. Nothing is ever written
into html_content.Every AI action spends credits from user_credits.credits_remaining
(src/lib/billing/credits.ts) instead of a flat rate limit — 1 credit per
Create Anything request or Ionexa Chat message, 40 for an AI agent, 50 for
an automation, 100 for a website, 300 for an app. An action is blocked
with "Not enough credits..." if the balance is too low; every
grant/spend/purchase is logged to credit_transactions (run
supabase_credits_schema.sql once, after supabase_schema.sql, to create
both tables — additive only, RLS grants users read-only access to their
own rows, every write goes through the service-role client).
api/signup) for Free, and
re-synced by /api/webhooks/stripe on checkout.session.completed,
customer.subscription.updated/deleted, and invoice.paid (the event
that actually fires on a normal monthly renewal). /api/cron/reset-credits
runs monthly from vercel.json (0 3 1 * *, 03:00 UTC on the 1st) with
CRON_SECRET set, as a safety net for accounts whose credits didn't get
reset by a Stripe event that cycle — Free accounts never touch Stripe at
all, so for them this IS the reset.CREDIT_PACKS in src/lib/billing/plans.ts) via /api/credits/checkout
(Stripe Checkout, mode: "payment", not a subscription); credits are
granted on checkout.session.completed for that payment-mode session,
from the session's own metadata.CreditsProvider (components/credits/credits-context.tsx)
seeds the top-nav balance from a server-fetched value in
dashboard/layout.tsx, then every credit-spending action calls
refresh() afterward so the number updates without a page reload.src/lib/auth/admin-emails.ts defines an ADMIN_EMAILS allowlist, read only from
the ADMIN_EMAILS env var (no address is written in the code since 2026-10-04;
unset, nobody is an admin). Any signed-in
user whose email is on that list is treated as Enterprise tier with
unlimited credits and full plan capabilities everywhere the app would
otherwise check subscription_tier or a credit balance — no real Stripe
subscription required. This is resolved server-side per request from the
caller's own session; it's never bundled into client JavaScript and never
shown to, or inferable by, any other user. The only visible trace is a
"Owner Access" badge on /dashboard/settings, shown only to the admin
account itself in place of the normal billing button.
A separate, independent system from Admin access above —
the two never overlap and one can be reconfigured without touching the
other. Set BETA_INVITE_CODE to any string; entering that exact code
(case-sensitive) in the optional "Invite code" field at signup grants the
new account full Ultimate-tier access (subscription_tier: "ultimate",
is_beta_tester: true in user_metadata, 25,000 credits granted, and —
same mechanism as admin — every credit deduction in api/chat,
api/create, api/modules/create, and api/text-actions skipped
entirely) with no Stripe subscription. Leaving the field blank, or typing
the wrong code, never blocks signup — the account is just created on the
normal Free plan, same as today.
If BETA_INVITE_CODE is unset, the field still appears but can never grant
anything (every signup falls through to Free) — safe to deploy without
configuring it.
Visible traces: a "Beta Tester" badge on /dashboard/settings (in place of
the "Upgrade Plan"/billing-portal button, alongside a working "Manage
Team" link since Ultimate includes team seats), and — starting 3 days
after the account's created_at — a small dismissible banner on Home
thanking them and linking to BETA_FEEDBACK_URL (falls back to a
mailto: placeholder if unset).
src/
app/
page.tsx # public landing page ("/")
pricing/page.tsx # 5-tier pricing + promo-code-enabled checkout
roadmap/page.tsx # public, non-interactive product roadmap
terms/, privacy/ # legal pages
login/ # email+password login/signup (shared form)
forgot-password/, reset-password/
dashboard/
layout.tsx # auth guard + sidebar shell (wraps every route)
loading.tsx, error.tsx # shared loading/error states
page.tsx # Ideas module (hand-built)
[module]/page.tsx # the other 12 modules, driven by lib/modules.ts
create/page.tsx # Create Anything
chat/page.tsx # Ionexa Chat
overview/page.tsx # Overview — default post-login landing
settings/page.tsx # account, billing summary, data export
team/page.tsx # team seat management (paid plans only)
api/
create/route.ts # server-only: Claude classifier → module table
chat/route.ts # server-only: Claude streaming chat (NDJSON)
signup/route.ts # signup + auto-confirm + auto sign-in + welcome email
checkout/, billing-portal/, webhooks/stripe/ # Stripe integration
team/invite/route.ts # team invite + email
weekly-digest/route.ts # placeholder, not yet scheduled — see Email above
layout.tsx, globals.css
components/
logo.tsx # Ionexa AI wordmark + icon-only mark (SVG)
dashboard/ # sidebar, top nav, page header, keyboard shortcuts
modules/, ideas/ # generic + hand-built module list/form/row components
create/ # Create Anything chat UI
chat/ # Ionexa Chat workspace, conversation sidebar, markdown rendering
overview/ # Overview cards/stats
settings/ # billing summary, export, danger zone, password change
team/ # invite form + member list
billing/ # subscribe + manage-billing buttons
toast/ # toast state/provider, used on every mutation
lib/
modules.ts # per-module table/field config + nav items
classifier-modules.ts # all 13 modules' fields, for /api/create's system prompt
admin.ts # ADMIN_EMAILS allowlist — see Admin access above
billing/ # plan metadata + Stripe price-id lookup
team/ # pending-invite auto-accept logic
email/ # Resend client + welcome/digest/invite templates
supabase/ # browser, server, and service-role clients
csv.ts, format-time.ts, use-sort-and-paginate.ts # shared list utilities
middleware.ts # session refresh + route protection
types/
tests/
smoke.spec.ts # Playwright smoke suite — see Testing above
supabase_schema.sql
Any Next.js host works (e.g. Vercel). Set the same
environment variables listed above in your hosting provider's dashboard,
and make sure supabase_schema.sql has been run against the Supabase
project you point it at.
This repository pins Node in two files that scripts/tests/node-version.test.mjs
holds in step, and the build asserts the version it is actually running on:
| where | value | who reads it |
|---|---|---|
.nvmrc | 22 | a person's version manager — the source of truth |
package.json engines.node | 22.x | npm, and the platform |
process.versions.node | must be major 22 | asserted by the gate, inside the build |
The fourth place is the Vercel dashboard: Settings → General → Node.js Version. It lives outside this repository and nothing here can read it. Check it by hand after any change to the project's settings, and whenever a deploy fails for no reason the code explains. It must say 22.x.
If it disagrees, the build is now the thing that tells you: the gate above
runs during npm run build, so it runs on the builder too, and it prints
the setting to change. A wrong dashboard version is a named failure rather
than a mysterious one.
Why 22 and not 20, measured 2026-09-25 rather than preferred:
@supabase/[email protected] and five of its sibling packages declare
"engines": { "node": ">=22.0.0" }, and Node 20 reached end of life on
2026-04-30 (nodejs/Release's own schedule.json; Node 22 runs to
2027-04-30). Nothing in src/ or scripts/ uses an API that Node 20 lacks
— the requirement comes from the dependency tree, not from this code.
in Vercel → Project → Settings → Environment Variables (Production), then redeploy — environment variables are read at build/boot, so an existing deployment keeps the old value until it is rebuilt.
Check it worked. src/lib/env-check.ts flags any address still
ending in @resend.dev as suspicious, so the startup check will say
so. Then trigger one real email (sign up a test account) and confirm
it arrives at an address that is NOT the Resend account owner's.
/dashboard/teamteamCollaborationteam_membersinviteddashboard/layout.tsxacceptPendingTeamInviteactivesubscription_tieruser_metadata/dashboard/settings) shows the current plan/seat count,
a "Manage Billing" button that opens a Stripe Billing Portal session
(/api/billing-portal) for anyone with a stripe_customer_id already
on file, a "Buy Credits" section, the add-ons panel, the usage-overage
opt-in, and the last 20 credit transactions.